{ "openapi": "3.1.0", "info": { "title": "Brilliant Directories API", "version": "2.0.0", "description": "Universal API for Brilliant Directories websites. Manage members, content, leads, email templates, categories, and more. Works with any BD-powered site - just provide your site URL and API key.\n\n## Authentication\nAll requests require an X-Api-Key header. Keys are created in BD Admin > Developer Hub > Generate API Key (see https://support.brilliantdirectories.com/support/solutions/articles/12000088768).\n\n## Rate Limiting\nDefault limit: **100 requests per 60 seconds per API key.**\n\nCustomers can request a higher limit from Brilliant Directories support - any value between **100 and 1,000 requests per minute.** This is set server-side by BD on request; it is NOT a self-service setting in the BD admin. If you expect heavy API usage, contact BD support before bulk operations.\n\nWhen the limit is exceeded, the API returns **HTTP 429 Too Many Requests**. Clients and AI agents should:\n\n- For bulk operations (imports, batch updates, mass exports), pace requests below the configured limit\n\n- On receiving 429, back off and retry after at least 60 seconds\n\n- For known-large jobs, either ask BD support to raise the limit first OR process in batches with delays\n\nThe /api/v2/token/verify endpoint can be called before bulk jobs to confirm credentials work.\n\n## Pagination\nList endpoints use cursor-based pagination via limit + page params. Default page size is 25, max 100.\n\n## Error Format\nAll errors: { \"status\": \"error\", \"message\": \"...\" } with standard HTTP codes (400 bad request, 401 unauthorized, 404 not found, 429 rate limited, 500 server error).", "contact": { "name": "Brilliant Directories", "url": "https://www.brilliantdirectories.com", "email": "support@brilliantdirectories.com" }, "license": { "name": "Proprietary" }, "x-api-version-note": "The info.version field represents BD's REST API version (currently v2 - /api/v2/* endpoints), NOT this MCP wrapper's release version.", "x-mcp-wrapper-version": "See https://www.npmjs.com/package/brilliant-directories-mcp for the current MCP wrapper version (npm package)." }, "servers": [ { "url": "{bd_site_url}", "description": "Your Brilliant Directories website", "variables": { "bd_site_url": { "default": "https://your-site.com", "description": "The full URL of your BD-powered website (e.g. https://yoursite.com)" } } } ], "security": [ { "apiKey": [] } ], "components": { "securitySchemes": { "apiKey": { "type": "apiKey", "in": "header", "name": "X-Api-Key", "description": "Your BD API key. Generate in BD Admin > Developer Hub > Generate API Key." } }, "parameters": { "limit": { "name": "limit", "in": "query", "description": "Records per page. Default 25 if omitted. Server hard-caps at 100 - values above 100 are silently clamped (verified: limit=150 returns 100 records + a next_page cursor, not 150). When `page` is present, `limit` is ignored (size is baked into the cursor). Recommended for context efficiency: 25 (most uses), 10 (scanning/filter loops), 5 (sampling). Parameter name is `limit` - `per_page` is silently ignored.", "schema": { "type": "integer", "default": 25, "maximum": 100 } }, "page": { "name": "page", "in": "query", "description": "Cursor token for pagination (use next_page value from previous response)", "schema": { "type": "string" } }, "property": { "name": "property", "in": "query", "description": "Field name to filter by (use property[] for multiple)", "schema": { "type": "string" } }, "include_password": { "name": "include_password", "in": "query", "description": "Opt in to return bcrypt `password` hash. Default stripped.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_subscription": { "name": "include_subscription", "in": "query", "description": "Opt in to return full `subscription_schema` (60+ plan fields). Default: `subscription_id` only.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_clicks": { "name": "include_clicks", "in": "query", "description": "Opt in to return `user_clicks_schema.clicks` array. Default: `total_clicks` count only.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_photos": { "name": "include_photos", "in": "query", "description": "Opt in to return `photos_schema` array. Default: `total_photos` count only (`image_main_file` URL always returned).", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_transactions": { "name": "include_transactions", "in": "query", "description": "Opt in to return full `transactions` invoice array. Default stripped (`revenue` rollup always returned).", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_profession": { "name": "include_profession", "in": "query", "description": "Opt in to return `profession_schema` (category metadata). Default: `profession_id` only.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_tags": { "name": "include_tags", "in": "query", "description": "Opt in to return `tags` array. Default stripped.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_services": { "name": "include_services", "in": "query", "description": "Opt in to return `services_schema` sub-category array. Default stripped.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_seo_hidden": { "name": "include_seo_hidden", "in": "query", "description": "Opt in to return SEO meta fields (`seo_page_*_hidden`, `seo_social_*_hidden`, `search_description`). Default stripped.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_about": { "name": "include_about", "in": "query", "description": "Opt in to return the `about_me` HTML bio. Default stripped.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_legacy_fields": { "name": "include_legacy_fields", "in": "query", "description": "Return image-import state on `photos_schema` rows: `original`, `resized`, `error`. Requires `include_photos=1`.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_content": { "name": "include_content", "in": "query", "description": "Opt in to return the full `post_content` HTML body. Default stripped (`post_title` + `post_caption` always returned).", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_view_flags": { "name": "include_view_flags", "in": "query", "description": "Opt in to return form-field view-flag columns: `field_input_view`, `field_display_view`, `field_search_view`, `field_email_view`, `field_grid_view`, `field_input_view_admin_only`, plus the 5 alt-label override columns. Default stripped — use when actively editing field visibility. See **Rule: Forms** § Field anatomy.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_meta": { "name": "include_meta", "in": "query", "description": "Opt in to return the `json_meta` longtext blob (UI rendering metadata + per-field validator config). Default stripped — use when adding or editing per-field validators (regexp, stringLength, etc.). See **Rule: Forms** § Field anatomy → `json_meta`.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_marketplace": { "name": "include_marketplace", "in": "query", "description": "Opt in to return the photo's marketplace/shop columns (`price`, `manufacturer`, `availability`, `product_category`, `product_type`, `condition`, `inv_id`, `link`, `additional_fields`). Default stripped — use when the site treats photos as a shop catalog (BD's marketplace feature).", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_post_seo": { "name": "include_post_seo", "in": "query", "description": "Opt in to return `post_meta_title`, `post_meta_description`, `post_meta_keywords`. Default stripped.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_author_full": { "name": "include_author_full", "in": "query", "description": "Opt in to return the full original `user` nested object (every field BD returns, including `password` hash, session `token`, `cookie`). Default: curated `author` summary (`user_id`, `first_name`, `last_name`, `company`, `email`, `phone_number`, `filename`, `image_main_file`, `subscription_id`, `active`).", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_category_schema": { "name": "include_category_schema", "in": "query", "description": "Opt in to restore full category metadata: `desc` (SEO description), `keywords`, `image`, `icon`, `sort_order`, `lead_price`, `revision_timestamp`. Default lean keeps: category ID + `name` + `filename` + hierarchy links (`profession_id` on top/sub, `master_id` on sub for sub-sub parent). Hierarchy is always visible so agents can traverse top -> sub -> sub-sub without opt-in.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_code": { "name": "include_code", "in": "query", "description": "Opt in to return the PHP/HTML code-template fields on post types: `search_results_div`, `search_results_layout`, `profile_results_layout`, `profile_header`, `profile_footer`, `category_header`, `category_footer`, `comments_code`. Default stripped. Only needed when editing post-type templates. Each field can be 1-30KB.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_post_comment_settings": { "name": "include_post_comment_settings", "in": "query", "description": "Opt in to return the `post_comment_settings` JSON-string field on post types (comment display / edit / delete settings).", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "include_review_notifications": { "name": "include_review_notifications", "in": "query", "description": "Opt in to return the 5 review-notification email template fields on post types: `review_admin_notification_email`, `review_member_notification_email`, `review_submitter_notification_email`, `review_approved_submitter_notification_email`, `review_member_pending_notification_email`.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, "property_value": { "name": "property_value", "in": "query", "description": "Value to filter by (use property_value[] for multiple)", "schema": { "type": "string" } }, "property_operator": { "name": "property_operator", "in": "query", "description": "Filter operator. Use word-form aliases (`<`, `>`, `<>`, `%` are WAF-stripped, symbol forms unreachable). Single-value: `eq`, `ne`/`neq`, `lt`, `lte`, `gt`, `gte`, `like`, `not_like`. CSV multi-value: `in`, `not_in` (`property_value=1,2,3`), `between` (`property_value=low,high` exactly 2 values). Value-ignored: `is_not_null`. For multi-condition AND across different fields use `property[]` + `property_value[]` + `property_operator[]` array-syntax. See **Rule: Filter operators** for full shape examples and validation behavior.", "schema": { "type": "string", "enum": [ "eq", "ne", "neq", "lt", "lte", "gt", "gte", "in", "not_in", "between", "like", "not_like", "is_not_null" ], "default": "eq" } }, "order_column": { "name": "order_column", "in": "query", "description": "Column to sort by", "schema": { "type": "string" } }, "order_type": { "name": "order_type", "in": "query", "description": "Sort direction", "schema": { "type": "string", "enum": [ "ASC", "DESC" ], "default": "ASC" } } }, "schemas": { "PaginatedResponse": { "type": "object", "properties": { "status": { "type": "string", "example": "success" }, "total": { "type": "integer" }, "current_page": { "type": "integer" }, "total_pages": { "type": "integer" }, "next_page": { "type": "string" }, "prev_page": { "type": "string" }, "message": { "type": "array", "items": { "type": "object" } } } }, "_ClearFields": { "type": "array", "items": { "type": "string" }, "description": "Column names to clear to empty string. Available on every `update*` operation. Works on base columns AND EAV/`users_meta` rows (rows preserved with `value=\"\"`). To actually clear a field you MUST use this parameter — sending the field with `\"\"` alone is a no-op (BD drops empty values). To remove a `users_meta` row entirely, use `deleteUserMeta`. See **Rule: Clearing fields**. Example: `_clear_fields: [\"h2\", \"hero_link_url\"]`." }, "SuccessResponse": { "type": "object", "properties": { "status": { "type": "string", "example": "success" }, "message": { "type": "object" } } }, "DeleteResponse": { "type": "object", "properties": { "status": { "type": "string", "example": "success" }, "message": { "type": "string" } } }, "User": { "type": "object", "properties": { "user_id": { "type": "integer" }, "email": { "type": "string", "format": "email" }, "first_name": { "type": "string" }, "last_name": { "type": "string" }, "company": { "type": "string" }, "phone_number": { "type": "string" }, "city": { "type": "string" }, "state_code": { "type": "string" }, "country_code": { "type": "string" }, "subscription_id": { "type": "integer" }, "password": { "type": "string", "writeOnly": true } } }, "Review": { "type": "object", "properties": { "review_id": { "type": "integer" }, "user_id": { "type": "integer" }, "review_name": { "type": "string" }, "review_email": { "type": "string" }, "review_title": { "type": "string" }, "review_description": { "type": "string" }, "rating_overall": { "type": "integer", "minimum": 1, "maximum": 5 }, "recommend": { "type": "integer", "enum": [ 0, 1 ] }, "review_status": { "type": "integer", "enum": [ 0, 2, 3, 4 ], "description": "Review status - authoritative values from BD admin:\n 0 = Pending (newly submitted, awaiting moderation)\n 2 = Accepted (approved and visible on the member profile)\n 3 = Declined (rejected by admin - not publicly visible)\n 4 = Waiting for Admin (member has pre-accepted, admin sign-off still required)\nValue 1 is NOT valid. Normal moderation flow: 0 -> 2 (accepted) or 0 -> 3 (declined). Use 4 when a member has accepted but admin review is still pending." } } }, "Click": { "type": "object", "properties": { "click_id": { "type": "integer" }, "user_id": { "type": "integer" }, "click_type": { "type": "string", "enum": [ "link", "phone", "email" ] }, "click_name": { "type": "string" }, "click_from": { "type": "string", "enum": [ "profile_page", "search_results" ] }, "click_url": { "type": "string" } } }, "Lead": { "type": "object", "properties": { "lead_id": { "type": "integer" }, "lead_name": { "type": "string" }, "lead_email": { "type": "string" }, "lead_phone": { "type": "string" }, "lead_message": { "type": "string" }, "lead_location": { "type": "string" }, "top_id": { "type": "integer" }, "token": { "type": "string" }, "date_added": { "type": "string" }, "lead_price": { "type": "number" }, "lead_status": { "type": "string" }, "flow_source": { "type": "string" }, "lead_notes": { "type": "string" } } }, "LeadMatch": { "type": "object", "properties": { "match_id": { "type": "integer" }, "lead_id": { "type": "integer" }, "user_id": { "type": "integer" }, "lead_matched": { "type": "string" }, "lead_points": { "type": "integer" }, "lead_match_notes": { "type": "string" }, "lead_status": { "type": "string" }, "match_price": { "type": "number" }, "lead_token": { "type": "string" }, "lead_matched_by": { "type": "string" }, "lead_viewed": { "type": "integer" }, "lead_type": { "type": "string" }, "lead_distance": { "type": "number" }, "lead_rating": { "type": "number" }, "lead_chosen": { "type": "integer" }, "lead_response": { "type": "string" }, "lead_accepted": { "type": "integer" }, "lead_updated": { "type": "string" } } }, "Post": { "type": "object", "properties": { "post_id": { "type": "integer" }, "user_id": { "type": "integer" }, "data_id": { "type": "integer", "description": "Parent post-type ID (data_categories.data_id, from listPostTypes)." }, "data_type": { "type": "string" }, "post_title": { "type": "string" }, "post_caption": { "type": "string" }, "post_content": { "type": "string", "description": "HTML content" }, "post_status": { "type": "integer", "enum": [ 0, 1 ], "description": "0=Draft, 1=Published" }, "post_price": { "type": "number" }, "post_token": { "type": "string" } } }, "PortfolioGroup": { "type": "object", "properties": { "group_id": { "type": "integer" }, "user_id": { "type": "integer" }, "data_id": { "type": "integer" }, "data_type": { "type": "string" }, "group_name": { "type": "string" }, "group_desc": { "type": "string" }, "group_status": { "type": "integer", "enum": [ 0, 1 ], "description": "0=Hidden, 1=Published" } } }, "PortfolioPhoto": { "type": "object", "properties": { "photo_id": { "type": "integer" }, "user_id": { "type": "integer" }, "group_id": { "type": "integer" }, "title": { "type": "string" }, "original_image_url": { "type": "string" }, "status": { "type": "integer", "enum": [ 0, 1 ] }, "order": { "type": "integer" } } }, "PostType": { "type": "object", "properties": { "data_id": { "type": "integer" }, "data_name": { "type": "string" }, "data_active": { "type": "integer", "enum": [ 0, 1 ] }, "category_tab": { "type": "string" }, "profile_tab": { "type": "string" }, "per_page": { "type": "integer" } } }, "Unsubscribe": { "type": "object", "properties": { "id": { "type": "integer" }, "email": { "type": "string", "format": "email" }, "definitive": { "type": "integer", "enum": [ 0, 1 ], "description": "1=permanent unsubscribe" }, "date": { "type": "string" }, "code": { "type": "string" }, "website_id": { "type": "integer" } } }, "Widget": { "type": "object", "properties": { "widget_id": { "type": "integer" }, "widget_name": { "type": "string" }, "widget_data": { "type": "string", "description": "HTML content" }, "widget_viewport": { "type": "string", "enum": [ "front", "admin", "both" ] }, "bootstrap_enabled": { "type": "integer", "enum": [ 0, 1 ] } } }, "EmailTemplate": { "type": "object", "properties": { "email_id": { "type": "integer" }, "email_name": { "type": "string", "description": "Internal name for this template. Lowercase, hyphens, no spaces — see **Rule: Email template recipe**." }, "email_type": { "type": "string" }, "email_subject": { "type": "string", "description": "Supports merge tags like %%%website_name%%%" }, "email_body": { "type": "string", "description": "HTML + merge tags + widget embeds. See **Rule: Email template recipe** for body conventions (no page chrome, inline styles only, gradient fallbacks)." }, "date_created": { "type": "string", "description": "Format: YYYYMMDDHHmmss" }, "triggers": { "type": "string", "description": "Comma-separated event triggers" }, "website": { "type": "integer", "description": "0 = platform-wide" }, "email_from": { "type": "string" }, "priority": { "type": "integer" }, "signature": { "type": "integer", "enum": [ 0, 1 ] }, "category_id": { "type": "integer", "enum": [ 0, 1, 3, 4, 15, 16 ], "description": "Template category. On `create`: default to `0` (My Saved Templates); other values (`1`/`3`/`4`/`15`/`16`) are system-populated — do NOT create under them. `update` is unrestricted in any category." }, "notemplate": { "type": "integer", "enum": [ 0, 1, 2, 3, 4 ], "description": "Template + logo wrapper mode. `0` = template + logo left; `2` = template + logo center (default — use unless user specifies otherwise or it's a plaintext email); `3` = template + logo right; `4` = template, no logo; `1` = no template or logo (plaintext-only). When this is anything other than `1`, BD's global template already wraps `email_body` in a 600px-wide constraining table — do NOT add your own outer max-width wrapper in that case." }, "content_type": { "type": "string" }, "revision_timestamp": { "type": "string" }, "unsubscribe_link": { "type": "integer", "enum": [ 0, 1 ] } } }, "Form": { "type": "object", "properties": { "form_id": { "type": "integer" }, "form_name": { "type": "string" }, "form_title": { "type": "string" }, "form_table": { "type": "string" }, "form_layout": { "type": "string" }, "form_action": { "type": "string" }, "form_class": { "type": "string" }, "form_email_on": { "type": "integer", "enum": [ 0, 1 ] }, "form_email_recipient": { "type": "string" }, "revision_timestamp": { "type": "string" } } }, "FormField": { "type": "object", "properties": { "field_id": { "type": "integer" }, "form_name": { "type": "string" }, "field_text": { "type": "string", "description": "Display label" }, "field_name": { "type": "string", "description": "DB column name" }, "field_type": { "type": "string", "enum": [ "text", "textarea", "select", "checkbox" ] }, "field_order": { "type": "integer" }, "field_required": { "type": "integer", "enum": [ 0, 1 ] }, "field_placeholder": { "type": "string" }, "field_display_view": { "type": "integer" }, "field_input_view": { "type": "integer" }, "field_email_view": { "type": "integer" } } }, "MembershipPlan": { "type": "object", "properties": { "subscription_id": { "type": "integer" }, "subscription_name": { "type": "string" }, "subscription_type": { "type": "string" }, "monthly_amount": { "type": "number" }, "yearly_amount": { "type": "number" }, "profile_type": { "type": "string", "enum": [ "paid", "free", "claim" ] }, "sub_active": { "type": "integer", "enum": [ 0, 1 ] }, "searchable": { "type": "integer", "enum": [ 0, 1 ] }, "search_priority": { "type": "integer" }, "payment_default": { "type": "string", "enum": [ "yearly", "monthly" ] } } }, "Menu": { "type": "object", "properties": { "menu_id": { "type": "integer" }, "menu_name": { "type": "string", "maxLength": 35 }, "menu_title": { "type": "string" }, "menu_location": { "type": "string" }, "menu_active": { "type": "integer", "enum": [ 0, 1 ] }, "menu_div_id": { "type": "string", "maxLength": 60 }, "menu_div_class": { "type": "string", "maxLength": 60 }, "menu_div_css": { "type": "string" }, "menu_div_code": { "type": "string" }, "menu_effects": { "type": "string", "maxLength": 60 } } }, "MenuItem": { "type": "object", "properties": { "menu_item_id": { "type": "integer" }, "menu_id": { "type": "integer" }, "menu_name": { "type": "string" }, "menu_link": { "type": "string" }, "master_id": { "type": "integer", "description": "0 for top-level, parent item ID for sub-items" }, "menu_order": { "type": "integer" }, "menu_active": { "type": "integer", "enum": [ 0, 1 ] }, "menu_target": { "type": "string", "enum": [ "_blank", "_self" ] }, "menu_class": { "type": "string" }, "menu_icon": { "type": "string" } } }, "Category": { "type": "object", "properties": { "category_id": { "type": "integer" }, "name": { "type": "string" }, "group_id": { "type": "integer" }, "filename": { "type": "string" }, "icon": { "type": "string" }, "keywords": { "type": "string", "description": "Fuzzy-search synonyms for on-site category matching - NOT SEO meta-keywords. Comma-separated single words (no spaces): synonyms, abbreviations, slang, common misspellings. Example for `Doctor`: `doc,physician,md,medic,gp,specialist`. ~5-10 max. Skip SEO phrases like `doctor near me` - those aren't fuzzy matchers. Optional." }, "revision_timestamp": { "type": "string" }, "json_meta": { "type": "string" } } }, "CategoryGroup": { "type": "object", "properties": { "group_id": { "type": "integer" }, "group_name": { "type": "string" }, "group_filename": { "type": "string" }, "group_desc": { "type": "string" }, "database": { "type": "string" } } }, "Service": { "type": "object", "properties": { "service_id": { "type": "integer" }, "name": { "type": "string" }, "desc": { "type": "string", "description": "Short internal description stored on the taxonomy row. NOT the field rendered on the public Sub-Category search-results page - most BD themes do not output this. For SEO copy on the category's search page (H1, intro paragraph, meta tags), create a WebPage with `seo_type=profile_search_results` and the matching slug instead. See `createWebPage` for the pattern." }, "profession_id": { "type": "integer", "description": "Parent category ID" }, "master_id": { "type": "integer" }, "filename": { "type": "string" }, "keywords": { "type": "string", "description": "Fuzzy-search synonyms for on-site category matching - NOT SEO meta-keywords. Comma-separated single words (no spaces): synonyms, abbreviations, slang, common misspellings. Example for `Doctor`: `doc,physician,md,medic,gp,specialist`. ~5-10 max. Skip SEO phrases like `doctor near me` - those aren't fuzzy matchers. Optional." }, "revision_timestamp": { "type": "string" }, "sort_order": { "type": "integer" }, "lead_price": { "type": "number" }, "image": { "type": "string" } } }, "UserService": { "type": "object", "properties": { "rel_id": { "type": "integer" }, "user_id": { "type": "integer" }, "service_id": { "type": "integer" }, "date": { "type": "string", "description": "Format: YYYYMMDDHHmmss" }, "avg_price": { "type": "number" }, "num_completed": { "type": "integer" }, "specialty": { "type": "integer", "enum": [ 0, 1 ] } } }, "UserPhoto": { "type": "object", "properties": { "photo_id": { "type": "integer" }, "user_id": { "type": "integer" }, "file": { "type": "string" }, "type": { "type": "string", "enum": [ "logo", "photo", "cover_photo" ] }, "date_added": { "type": "string" } } }, "UserMeta": { "type": "object", "properties": { "meta_id": { "type": "integer" }, "database": { "type": "string", "description": "Target table name" }, "database_id": { "type": "integer", "description": "Record ID in target table" }, "key": { "type": "string" }, "value": { "type": "string" }, "date_added": { "type": "string" }, "revision_timestamp": { "type": "string" } } }, "Tag": { "type": "object", "properties": { "id": { "type": "integer" }, "tag_name": { "type": "string" }, "group_tag_id": { "type": "integer" }, "added_by": { "type": "integer" } } }, "TagGroup": { "type": "object", "properties": { "id": { "type": "integer" }, "group_tag_name": { "type": "string" }, "added_by": { "type": "integer" }, "updated_by": { "type": "integer" } } }, "TagType": { "type": "object", "properties": { "id": { "type": "integer" }, "type_name": { "type": "string" }, "table_relation": { "type": "string" } } }, "TagRelationship": { "type": "object", "properties": { "id": { "type": "integer" }, "tag_id": { "type": "string" }, "object_id": { "type": "integer" }, "tag_type_id": { "type": "integer" }, "added_by": { "type": "integer" }, "created_at": { "type": "string" } } }, "SmartList": { "type": "object", "properties": { "smart_list_id": { "type": "integer" }, "smart_list_name": { "type": "string" }, "smart_list_type": { "type": "string", "enum": [ "members", "forms_inbox", "leads", "reviews", "transaction", "newsletter" ] }, "smart_list_created_by": { "type": "integer" }, "smart_list_query_params": { "type": "string" }, "schedule": { "type": "string" } } } } }, "paths": { "/api/v2/token/verify": { "get": { "operationId": "verifyToken", "summary": "Verify API key", "description": "Verify that your API key is valid and check rate limit status.\n\n**Use when:** at the start of any session or batch job, to confirm the API key is valid and the site is reachable BEFORE burning rate limit on real calls. Also useful for surfacing a clear \"bad credentials\" error to the user early.\n\n**Parameter interactions:**\n\n- Call at the start of a session to confirm the API key is valid BEFORE issuing real calls - saves rate-limit budget on key-config errors\n\n**Returns:** `{ status: \"success\"|\"error\", message: ... }` - BD's standard response envelope.\n\n", "tags": [ "Authentication" ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } } } }, "/api/v2/user/fields": { "get": { "operationId": "getUserFields", "summary": "Get user field definitions", "description": "Returns available fields for user records with labels and required flags. Use this to discover custom fields.\n\n**Use when:** building dynamic forms or importers - you need to discover which fields exist on the User record on THIS specific site (custom fields vary per BD site config). Also useful for validating import-CSV headers before running a batch.\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n", "tags": [ "Users" ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "type": "object" } } } } } } }, "/api/v2/user/get": { "get": { "operationId": "listUsers", "summary": "List members/users", "description": "Get a paginated list of all members. Supports filtering by any user field and sorting.\n\n**Lean by default:** each row strips `subscription_schema`, `user_clicks_schema.clicks`, `photos_schema`, `transactions`, `profession_schema`, `tags`, `services_schema`, `password`, and SEO-hidden meta. Lean row keeps core columns + `revenue` rollup + summary counts (`total_clicks`, `total_photos`) + `image_main_file`. Opt back in per call with the `include_*` flags.\n\n**Use when:** enumerating members for reports, CSV exports, bulk status updates, analytics, or pagination through the full member base. Also used for lookups by field - pass `property=email` + `property_value=` to find a single user by email. For keyword/text search use `searchUsers`; for a single user by known `user_id` use `getUser`.\n\n**Pagination:** cursor-based. Pass `limit` (default 25, max 100) and `page` token from the previous response's `next_page`. Do not assume integer offsets.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**Enums:** `property_operator`: `=`, `LIKE`, `>`, `<`, `>=`, `<=`; `order_type`: `ASC`, `DESC`.\n\n**Filter-property rule - use ACTUAL field names:** `property` must reference a real column on `users_data` or a valid custom user field. If you don't know what's filterable, call `getUserFields` first - it returns the authoritative list for this site (includes custom fields). BD returns misleading errors like `\"user not found\"` when `property` names a nonexistent field - that is a BAD FILTER, not a 404 on the endpoint. Do not invent properties like `user_group` (not a real column).\n\n**Filtering by TOP CATEGORY (profession):** the filter column is `profession_id` (integer), not a category name string. If the caller gives you a category name, chain: (1) `listTopCategories` -> find the row whose `name` matches; (2) grab its `profession_id`; (3) call `listUsers` with `property=profession_id&property_value=`. Same principle for any taxonomy filter - resolve names to IDs first via `listSubCategories`, `listMembershipPlans`, etc. For sub-category filtering on users, the authoritative approach is `listMemberSubCategoryLinks` filtered by `service_id` -> collect `user_id`s -> fetch those users. (There is also a `service` CSV column on user records but exact-match filtering on it requires the complete CSV value and LIKE syntax support is not guaranteed - prefer the link-table route.)\n\n**Filtering by `users_meta` (custom/meta fields):** multi-value meta filtering IS supported via the array syntax - e.g. `property[]=&property_value[]=foo&property[]=&property_value[]=bar&property_operator[]=OR&property_logic[]==`. Use this for OR-across-meta-values lookups (e.g. members whose custom field equals any of N options).\n\n**Payment-method field in response:** every user record includes `card_info`. When no card is on file it is the literal boolean `false` (BD's convention - same as `tags: false`, `transactions: false`, etc.). When a card IS stored, it's an object with fields like `last4`, `brand`, `name`. Check `card_info && card_info.last4` (truthy-guard) - NOT `card_info.last4` directly, since `false.last4` throws. This is the authoritative signal for \"does this member have a valid payment method on file\" - do not infer from `subscription_id` alone. BD clears `card_info` back to `false` when the member removes their card; it does not go stale silently.\n\n**See also:** `getUser` (single record by ID), `searchUsers` (keyword search), `getUserFields` (list filterable fields).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n**Profile URL:** every user record has a `filename` field. To get the full public profile URL, concatenate: `/`. The `filename` is the complete relative path (e.g., `united-states/monterey-park/doctor/harrison-hasanuddin-d-o`) - DO NOT prepend `/business/`, `/profile/`, `/member/`, or any other segment. BD's router resolves `filename` verbatim.\n\n", "tags": [ "Users" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/property" }, { "$ref": "#/components/parameters/property_value" }, { "$ref": "#/components/parameters/property_operator" }, { "$ref": "#/components/parameters/order_column" }, { "$ref": "#/components/parameters/order_type" }, { "$ref": "#/components/parameters/include_password" }, { "$ref": "#/components/parameters/include_subscription" }, { "$ref": "#/components/parameters/include_clicks" }, { "$ref": "#/components/parameters/include_photos" }, { "$ref": "#/components/parameters/include_transactions" }, { "$ref": "#/components/parameters/include_profession" }, { "$ref": "#/components/parameters/include_tags" }, { "$ref": "#/components/parameters/include_services" }, { "$ref": "#/components/parameters/include_seo_hidden" }, { "$ref": "#/components/parameters/include_about" }, { "$ref": "#/components/parameters/include_legacy_fields" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } } } }, "/api/v2/user/get/{user_id}": { "get": { "operationId": "getUser", "summary": "Get a single member/user", "tags": [ "Users" ], "parameters": [ { "name": "user_id", "in": "path", "required": true, "schema": { "type": "integer" } }, { "$ref": "#/components/parameters/include_password" }, { "$ref": "#/components/parameters/include_subscription" }, { "$ref": "#/components/parameters/include_clicks" }, { "$ref": "#/components/parameters/include_photos" }, { "$ref": "#/components/parameters/include_transactions" }, { "$ref": "#/components/parameters/include_profession" }, { "$ref": "#/components/parameters/include_tags" }, { "$ref": "#/components/parameters/include_services" }, { "$ref": "#/components/parameters/include_seo_hidden" }, { "$ref": "#/components/parameters/include_about" }, { "$ref": "#/components/parameters/include_legacy_fields" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single user record. Read-only.\n\n**Lean by default:** response strips the same heavy nested buckets as `listUsers` (see **Rule: Lean read responses**). Pass `include_*=1` flags to restore specific nested fields.\n\n**Use when:** you already have the `user_id` (from `listUsers`, `searchUsers`, a prior create, or a webhook payload) and need the full member record. Cheaper than `listUsers` + filter. For lookups by email or other field, use `listUsers` with `property`/`property_value`.\n\n**Required:** `user_id`.\n\n**See also:** `listUsers` (enumerate many), `searchUsers` (keyword search).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n**Payment method on file:** the response includes a `card_info` field. When no card is stored it is the literal boolean `false` (BD convention). When a card IS stored, it's an object with fields like `last4`, `brand`, `name`. Use `card_info && card_info.last4` to safely check - `card_info.last4` would throw on the `false` case. This is the authoritative signal for \"does this member have a payment method\" - don't infer from `subscription_id` alone. BD clears `card_info` back to `false` when the member removes their card; it does not go stale silently.\n\n**Profile URL:** every user record has a `filename` field. To get the full public profile URL, concatenate: `/`. The `filename` is the complete relative path (e.g., `united-states/monterey-park/doctor/harrison-hasanuddin-d-o`) - DO NOT prepend `/business/`, `/profile/`, `/member/`, or any other segment. BD's router resolves `filename` verbatim. **Note:** `filename` is regenerated by BD when member inputs that influence the slug change (category, city, etc.). The value you see NOW is current-as-of-this-read. If you call `updateUser` afterward, re-fetch before using the filename in URL-referencing content (blog posts, emails, redirects).\n\n" } }, "/api/v2/user/create": { "post": { "operationId": "createUser", "summary": "Create a new member/user", "tags": [ "Users" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "email", "password", "subscription_id" ], "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string" }, "subscription_id": { "type": "integer", "description": "Membership plan ID" }, "first_name": { "type": "string" }, "last_name": { "type": "string" }, "company": { "type": "string" }, "phone_number": { "type": "string" }, "city": { "type": "string" }, "state_code": { "type": "string" }, "country_code": { "type": "string" }, "active": { "type": "string", "enum": [ "1", "2", "3", "4", "5", "6" ], "description": "User account status. BD does NOT validate - integers outside the set store as-is (observed: `99`). Stick to documented values:\n\n- `1` = Not Active (requires activation)\n\n- `2` = Active (live)\n\n- `3` = Canceled\n\n- `4` = On Hold (requires moderation)\n\n- `5` = Past Due\n\n- `6` = Incomplete (rare - paid signup hit an issue; member created but unpaid/stuck)\n\n**Read caveat:** top-level `status` response field (`\"Active\"`, `\"Not Active\"`, etc.) is a computed label. When `active` is an unknown value, `status` is OMITTED from the response - don't treat `status` as always-present." }, "listing_type": { "type": "string", "enum": [ "Individual", "Company" ], "default": "Company", "description": "Listing type classification. Canonical values (EXACT case): `Individual` or `Company`. BD does NOT validate - off-canonical values (`individual`, `INDIVIDUAL`, `Business`) store verbatim and break downstream `listing_type === \"Individual\"` checks.\n\n**Always include explicitly on `createUser`.** BD's server-side default when omitted is `Individual` - if you want `Company` as your default (recommended for scraped business listings, CSV imports), you MUST send it explicitly; do NOT rely on omission. Default to `Company`, override to `Individual` when: (1) user explicitly says so, (2) structured input specifies `listing_type` per-record (normalize case client-side), (3) source clearly describes a person, not a business." }, "verified": { "type": "string", "enum": [ "1", "0" ], "description": "If YES, a verified icon badge will display on the user's listing.\\n\\nValid values:\\n 1 = Yes\\n 0 = No" }, "signup_date": { "type": "string", "description": "User signup date. Format: `YYYYMMDDHHmmss` in the site's timezone. BD silently truncates other formats, corrupting the value. BD auto-fills on create — omit unless backfilling legacy signup dates during import/migration." }, "profession_id": { "type": "integer", "description": "Assign this user to a top level category. Input the ID number of the top level category." }, "services": { "type": "string", "description": "Assign this user to sub-level categories. Input a comma-separated list of sub-level IDs or names (NO spaces around commas - `\"1823,1824\"` not `\"1823, 1824\"`)." }, "address1": { "type": "string", "description": "The user's street address." }, "address2": { "type": "string", "description": "The user's unit or suite number." }, "auto_geocode": { "type": "string", "enum": [ "1", "0" ], "description": "OPTIONAL: Allow Google to geocode users. Requires \"Pretty URLs with Google Maps\". Search BD support to learn more.\\n\\nValid values:\\n 1 = Yes\\n 0 = No" }, "zip_code": { "type": "string", "description": "The user's zip / postal code." }, "state_ln": { "type": "string", "description": "OPTIONAL: Enter the full name of the state / province for this user." }, "country_ln": { "type": "string", "description": "OPTIONAL: Enter the full name of the country for this user." }, "lat": { "type": "string", "description": "OPTIONAL: Enter latitude coordinates for the location of this user." }, "lon": { "type": "string", "description": "OPTIONAL: Enter longitude coordinates for the location of this user." }, "nationwide": { "type": "string", "enum": [ "1", "0" ], "description": "If YES, the user's listing will be found in all geographical location searches.\\n\\nValid values:\\n 1 = Yes\\n 0 = No" }, "member_tags": { "type": "string", "description": "ADDITIONAL: Assign Member Tag ID" }, "search_description": { "type": "string", "description": "Short description shown under the user's name on search result pages. **170-char limit.**", "maxLength": 170 }, "about_me": { "type": "string", "description": "Long description of the member/user. Renders on their public profile. Froala body field — use `

`/`

`/`

`/`
    `/`
      ` structure; skip images unless user asks. HTML allowed (per universal safe-HTML rule)." }, "quote": { "type": "string", "description": "OPTIONAL: Enter the user's personal quote, motto or slogan." }, "experience": { "type": "integer", "description": "OPTIONAL: Enter the year that the user's company was established. Example: 1982" }, "affiliation": { "type": "string", "description": "OPTIONAL: Enter the accepted forms of payment this user accepts." }, "awards": { "type": "string", "description": "OPTIONAL: Enter honors, awards or accolades this user has received." }, "rep_matters": { "type": "string", "description": "OPTIONAL: Enter the hours of operation for the member. EG: Monday - Friday, 9am - 5pm" }, "position": { "type": "string", "description": "OPTIONAL: Enter the user's position, title or role at their company. Example: Account Executive" }, "website": { "type": "string", "description": "Enter the FULL URL of the user's website. Must begin with https://" }, "booking_link": { "type": "string", "description": "Enter the FULL URL of the user's booking page. Must begin with https://" }, "blog": { "type": "string", "description": "Enter the FULL URL of the user's blog. Must begin with https://" }, "facebook": { "type": "string", "description": "Enter the FULL URL of the user's Facebook account. Must begin with https://" }, "twitter": { "type": "string", "description": "Enter the FULL URL of the user's Twitter account. Must begin with https://" }, "linkedin": { "type": "string", "description": "Enter the FULL URL of the user's Linkedin account. Must begin with https://" }, "youtube": { "type": "string", "description": "Enter the FULL URL of the user's YouTube account. Must begin with https://" }, "instagram": { "type": "string", "description": "Enter the FULL URL of the user's Instagram account. Must begin with https://" }, "pinterest": { "type": "string", "description": "Enter the FULL URL of the user's Pinterest account. Must begin with https://" }, "tiktok": { "type": "string", "description": "Enter the FULL URL of the user's Tiktok account. Must begin with https://" }, "snapchat": { "type": "string", "description": "Enter the FULL URL of the user's Snapchat account. Must begin with https://" }, "whatsapp": { "type": "string", "description": "Enter the FULL URL of the user's Whatsapp account. Must begin with https://" }, "profile_photo": { "type": "string", "description": "Profile photo URL (member headshot). **Bare URL only — no `?query`, must end in `.jpg`/`.jpeg`/`.png`/`.webp`.** Query strings get baked into imported filenames and 404. Pair with `auto_image_import=1` to fetch externals into site storage." }, "logo": { "type": "string", "description": "Logo URL (brand/business mark). **Bare URL only — no `?query`, must end in `.jpg`/`.jpeg`/`.png`/`.webp`.** Query strings get baked into imported filenames and 404. Pair with `auto_image_import=1` to fetch externals into site storage." }, "cover_photo": { "type": "string", "description": "Cover photo URL. **LANDSCAPE only — never portrait/vertical; source via `https://www.pexels.com/search//?orientation=landscape`; bare URL, no `?query`, must end in `.jpg`/`.jpeg`/`.png`/`.webp` — see **Rule: Image URLs**.** Query strings get baked into imported filenames and 404. Pair with `auto_image_import=1` to fetch externals into site storage." }, "auto_image_import": { "type": "string", "enum": [ "1", "0" ], "description": "If YES, system will import user images and save them to your website. Processing may take several minutes after import.\\n\\nValid values:\\n 1 = Yes\\n 0 = No" }, "profession_name": { "type": "string", "description": "Alternative to `profession_id` - pass the top-level category NAME as a string. BD looks it up in `list_professions`.\n\n**Create vs update asymmetry (silent-failure trap):**\n\n- `createUser` -> unknown names auto-create (hardcoded)\n\n- `updateUser` -> unknown names **SILENTLY SKIPPED** unless `create_new_categories=1` is also passed. The write succeeds and returns success; the category just doesn't change. Always pass `create_new_categories=1` on update when supplying a `profession_name` that might not exist." }, "send_email_notifications": { "type": "integer", "enum": [ 0, 1 ], "description": "Set to `1` to trigger the welcome email on member creation (based on the membership plan's configured email). Default: off - API creates are silent because they typically aren't self-signups. Only set this when you WANT the member notified." }, "last_login": { "type": "string", "description": "Timestamp of user's last login. Format: `YYYYMMDDHHmmss` in the site's timezone. BD silently truncates other formats, corrupting the value. BD updates on each login — omit unless backfilling historical data during import/migration." }, "filename": { "type": "string", "description": "Override BD's auto-generated URL slug (e.g. `united-states/los-angeles/doctor/jane-smith`). If omitted, BD derives it from city/category/name.\n\n**Usually OMIT** - manual values get regenerated by BD on future updates that change URL-influencing fields, silently overwriting your override." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a member. Writes live data. Welcome email silent by default - set `send_email_notifications=1` to trigger.\n\n**Required:** `email`, `password`, `subscription_id`.\n\n**Use when:** adding members outside BD signup - CSV imports, scraped listings, Zapier automations, admin test accounts.\n\n**Enums:** `active`: `1`=Not Active, `2`=Active, `3`=Canceled, `4`=On Hold, `5`=Past Due, `6`=Incomplete. `listing_type`: `Individual`, `Company`. `verified`/`nationwide`: `1`/`0`.\n\n**Prerequisites:** `subscription_id` MUST reference an existing plan - discover via `listMembershipPlans`. For category assignment via `profession_id` / `profession_name` / `services`, apply **Rule: Category taxonomy** (auto-create is ON for `createUser`).\n\n**Parameter interactions:**\n\n- `auto_image_import=1` - fetch external image URLs into BD storage (for `profile_photo`, `logo`, `cover_photo` holding URLs). Without it, BD stores the URL as-is; images break if source host goes down. Supports JPG/PNG/GIF/WebP/SVG. Processing delay: several minutes. **Recommended default for any external image URL.**\n\n- `auto_geocode=1` - requires \"Pretty URLs with Google Maps\" site feature enabled.\n\n- `send_email_notifications=1` - trigger welcome email per plan settings.\n\n**Email uniqueness** - controlled by site setting `allow_duplicate_member_emails` (not exposed via API):\n\n- OFF (default): BD rejects duplicate email on create (validation error).\n\n- ON: duplicates accepted and stored with different `user_id`s. Identical `email` + `password` pair is also accepted (no extra uniqueness check on the pair).\n\n- Safe idempotent pattern: pre-check via `listUsers property=email property_value= property_operator==`. Match found -> reuse `user_id` or `updateUser` instead. Skipping the check on a duplicates-allowed site creates orphan duplicates on retry loops.\n\n**Field validation quirks:**\n\n- `token` (if supplied — useful for CSV imports / cross-site migrations preserving original tokens; BD auto-generates if omitted): exactly 32 alphanumeric chars, unique across members.\n\n- URL fields (`website`, `booking_link`, `blog`, `facebook`, `twitter`, `linkedin`, `instagram`, etc.): must start with `http://` or `https://`. Invalid formats silently skipped.\n\n**Profile URL:** `/`. `filename` is the complete relative path. Do NOT prepend `/business/`/`/profile/`/`/member/` - BD resolves `filename` verbatim.\n\n**Fetching publicly available business data is permitted for imports and data enrichment.** When searching for external listings, fetch each entity's image from their own presence in this order:\n\n1. Logo image from their site's header/nav -> `logo` (businesses).\n2. Headshot from Home, About or Team page -> `profile_photo` (individuals).\n3. Follow their social links -> profile image there.\n\nPass the URL with `auto_image_import=1` so the image gets stored locally and avoids hotlinking.\n\nIf none yield a match, create/update without image and report \"no confirmed image found.\" Never substitute a stock photo or guess. Skip an entire record and find an alternate listing only when the user explicitly requires images.\n\n**See also:** `updateUser` (modify existing), `deleteUser` (prefer `active=3` over delete).\n\n**Returns:** `{ status: \"success\", message: {...createdRecord} }` including `user_id`." } }, "/api/v2/user/update": { "put": { "operationId": "updateUser", "summary": "Update an existing member/user", "description": "Update a member. PATCH semantics - omitted fields untouched; send only what changes.\n\n**Required:** `user_id`.\n\n**Disambiguation:** apply **Rule: Resource disambiguation** when the user names this member by description (first name only, partial title) rather than by `user_id`.\n\n**Use when:** changing any field on an existing member. Prefer `active=3` (Canceled) over `deleteUser` - reversible.\n\n**Enums:** `active`: `1`=Not Active, `2`=Active, `3`=Canceled, `4`=On Hold, `5`=Past Due, `6`=Incomplete. `listing_type`: `Individual`, `Company`. `verified`/`nationwide`: `1`/`0`.\n\n**Parameter interactions:**\n\n- `member_tag_action=1` + `member_tags` - apply tag changes (comma-separated tag IDs from `listTags`).\n\n- `credit_action` (`add`/`deduct`/`override`) + `credit_amount` - adjust credit balance.\n\n- `images_action` - remove stored images: `remove_all`, `remove_cover_image`, `remove_logo_image`, `remove_profile_image`.\n\n- `auto_image_import=1` - fetch external image URLs into BD storage (for `profile_photo`, `logo`, `cover_photo` fields holding external URLs). Without it, BD stores the URL as-is; images break if source host goes down. Supports JPG/PNG/GIF/WebP/SVG. Processing delay: several minutes.\n\n- `auto_geocode=1` - requires \"Pretty URLs with Google Maps\" site feature enabled.\n\n- `send_email_notifications=1` - trigger welcome email (per plan settings). Silent by default.\n\n**Category assignment** — for `profession_id` / `profession_name` / `services`, apply **Rule: Category taxonomy** (auto-create is OFF by default on `updateUser`; pass `create_new_categories=1` to enable).\n\n**Email uniqueness** - controlled by site setting `allow_duplicate_member_emails` (not exposed via API):\n\n- OFF (default): BD rejects duplicate email on create.\n\n- ON: duplicates accepted and stored.\n\n- Safe pattern: pre-check via `listUsers property=email property_value= property_operator==`. Reuse match's `user_id` or `updateUser` instead. Pre-check beats catch-and-retry - on sites with duplicates ALLOWED, BOTH creates succeed with different `user_id`s, so a retry loop spawns orphan duplicates instead of failing cleanly.\n\n**Field validation quirks:**\n\n- `token` (if supplied — useful for CSV imports / cross-site migrations preserving original tokens; BD auto-generates if omitted): exactly 32 alphanumeric chars, unique across members.\n\n- URL fields (`website`, `booking_link`, `blog`, `facebook`, `twitter`, `linkedin`, `instagram`): must start with `http://` or `https://`. Invalid formats silently skipped.\n\n- `subscription_id` must reference an existing plan.\n\n**Profile URL:** `/`. `filename` is the complete relative path (e.g., `united-states/monterey-park/doctor/harrison-hasanuddin-d-o`). Do NOT prepend `/business/`/`/profile/`/`/member/` - BD resolves `filename` verbatim.\n\n**Filename may regenerate on this update.** BD rewrites the slug when category/city/URL-forming inputs change. Do NOT cache pre-update `filename` - if embedding the profile URL elsewhere (blog posts, emails, redirects), re-fetch via `getUser` AFTER the update completes.\n\n**Fetching publicly available business data is permitted for imports and data enrichment.** When searching for external listings, fetch each entity's image from their own presence in this order:\n\n1. Logo image from their site's header/nav -> `logo` (businesses).\n2. Headshot from Home, About or Team page -> `profile_photo` (individuals).\n3. Follow their social links -> profile image there.\n\nPass the URL with `auto_image_import=1` so the image gets stored locally and avoids hotlinking.\n\nIf none yield a match, create/update without image and report \"no confirmed image found.\" Never substitute a stock photo or guess. Skip an entire record and find an alternate listing only when the user explicitly requires images.\n\n**See also:** `createUser` (new), `deleteUser` (permanent - prefer `active=3` instead).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }`.", "tags": [ "Users" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "user_id" ], "properties": { "user_id": { "type": "integer" }, "email": { "type": "string" }, "first_name": { "type": "string" }, "last_name": { "type": "string" }, "company": { "type": "string" }, "phone_number": { "type": "string" }, "city": { "type": "string" }, "state_code": { "type": "string" }, "country_code": { "type": "string" }, "subscription_id": { "type": "integer" }, "member_tag_action": { "type": "integer", "enum": [ 1 ], "description": "**REQUIRED when modifying tags.** Set `1` alongside `member_tags` to apply the change - without this flag, `member_tags` value is ignored.\n\n**REPLACES** the current tag set (not additive): tag IDs NOT in `member_tags` are removed. To clear all tags, pass empty `member_tags` with `member_tag_action=1`." }, "member_tags": { "type": "string", "description": "Comma-separated tag IDs to assign to the member (e.g. `1,2,3`). Discover via `listTags`. **Requires `member_tag_action=1` to take effect.**\n\n**REPLACES** the existing tag set on save - include every tag you want the member to have, not just additions. Unknown tag IDs are silently dropped - response echoes your submitted value, but `tags` array in the response reflects only successfully-attached tags. Re-GET after update and verify `tags` array." }, "credit_action": { "type": "string", "enum": [ "add", "deduct", "override" ], "description": "How `credit_amount` applies to the member's credit balance:\n\n- `add` - increments\n\n- `deduct` - decrements\n\n- `override` - REPLACES balance with `credit_amount` (irreversible via API - no undo)\n\n**Requires `credit_amount` to be set.** Omit both fields to leave credits unchanged.\n\nBD does NOT reject deducts/overrides that produce negative balance. If non-negative required, validate client-side against current `credit_balance` (dollar-formatted string, may be negative) before calling." }, "credit_amount": { "type": "number", "description": "Credit amount (number, may include decimals) paired with `credit_action`. Meaning depends on the action: for `add` and `deduct` it's the delta; for `override` it's the new absolute balance. Ignored if `credit_action` is not set." }, "images_action": { "type": "string", "enum": [ "remove_all", "remove_cover_image", "remove_logo_image", "remove_profile_image" ], "description": "Removes stored member images. PERMANENT via API - no undo.\n\n- `remove_all` - clears all three (profile, logo, cover)\n- `remove_profile_image` / `remove_logo_image` / `remove_cover_image` - clears only that image\n\n**To REPLACE** an image instead of removing, pass the new `profile_photo` / `logo` / `cover_photo` URL (optionally with `auto_image_import=1`). Do NOT set `images_action` for replacement." }, "services": { "type": "string", "description": "Sub-categories for this member. Formats: `category=>service1,service2` OR `service1,service2` (top category defaults to member's current `profession_id`). Supports sub-sub-categories via `Parent=>Child` (e.g. `Honda=>2022,Honda=>2023,Toyota`).\n\nUnknown names silently ignored unless `create_new_categories=1` is also set (on update - create auto-creates always).\n\n**WARNING:** changing `profession_id` in the same call WIPES all existing sub-category links. Re-send the full `services` list to preserve them." }, "auto_geocode": { "type": "string", "enum": [ "0", "1" ], "description": "Use Google Maps to geocode this user's location. Requires the \"Pretty URLs with Google Maps\" feature to be enabled on the site. (string, \"1\" or \"0\").\\n 1 = Yes\\n 0 = No" }, "active": { "type": "string", "enum": [ "1", "2", "3", "4", "5", "6" ], "description": "User account status. BD does NOT validate - integers outside the set store as-is (observed: `99`). Stick to documented values:\n\n- `1` = Not Active (requires activation)\n\n- `2` = Active (live)\n\n- `3` = Canceled\n\n- `4` = On Hold (requires moderation)\n\n- `5` = Past Due\n\n- `6` = Incomplete (rare - paid signup hit an issue; member created but unpaid/stuck)\n\n**Read caveat:** top-level `status` response field (`\"Active\"`, `\"Not Active\"`, etc.) is a computed label. When `active` is an unknown value, `status` is OMITTED from the response - don't treat `status` as always-present." }, "listing_type": { "type": "string", "enum": [ "Individual", "Company" ], "default": "Company", "description": "Listing type classification. **BD does NOT validate this field - any string is stored verbatim.** Canonical values (exact case): `Individual` or `Company`. On `updateUser`, include only when reclassifying an existing member (e.g. fixing a record originally created as `Individual` that should be `Company`). If including, normalize case client-side - `individual` or other off-canonical casings will store as-is and break downstream logic. Otherwise omit to preserve the current value." }, "verified": { "type": "string", "enum": [ "1", "0" ], "description": "If YES, a verified icon badge will display on the user's listing.\\n\\nValid values:\\n 1 = Yes\\n 0 = No" }, "signup_date": { "type": "string", "description": "User signup date. Format: `YYYYMMDDHHmmss` in the site's timezone. BD silently truncates other formats, corrupting the value. BD auto-fills on create — omit unless backfilling legacy signup dates during import/migration." }, "profession_id": { "type": "integer", "description": "Assign this user to a top level category. Input the ID number of the top level category." }, "address1": { "type": "string", "description": "The user's street address." }, "address2": { "type": "string", "description": "The user's unit or suite number." }, "zip_code": { "type": "string", "description": "The user's zip / postal code." }, "state_ln": { "type": "string", "description": "OPTIONAL: Enter the full name of the state / province for this user." }, "country_ln": { "type": "string", "description": "OPTIONAL: Enter the full name of the country for this user." }, "lat": { "type": "string", "description": "OPTIONAL: Enter latitude coordinates for the location of this user." }, "lon": { "type": "string", "description": "OPTIONAL: Enter longitude coordinates for the location of this user." }, "nationwide": { "type": "string", "enum": [ "1", "0" ], "description": "If YES, the user's listing will be found in all geographical location searches.\\n\\nValid values:\\n 1 = Yes\\n 0 = No" }, "search_description": { "type": "string", "description": "Short description shown under the user's name on search result pages. **170-char limit.**", "maxLength": 170 }, "about_me": { "type": "string", "description": "Long description of the member/user. Renders on their public profile. Froala body field — use `

      `/`

      `/`

      `/`
        `/`
          ` structure; skip images unless user asks. HTML allowed (per universal safe-HTML rule)." }, "quote": { "type": "string", "description": "OPTIONAL: Enter the user's personal quote, motto or slogan." }, "experience": { "type": "integer", "description": "OPTIONAL: Enter the year that the user's company was established. Example: 1982" }, "affiliation": { "type": "string", "description": "OPTIONAL: Enter the accepted forms of payment this user accepts." }, "awards": { "type": "string", "description": "OPTIONAL: Enter honors, awards or accolades this user has received." }, "rep_matters": { "type": "string", "description": "OPTIONAL: Enter the hours of operation for the member. EG: Monday - Friday, 9am - 5pm" }, "position": { "type": "string", "description": "OPTIONAL: Enter the user's position, title or role at their company. Example: Account Executive" }, "website": { "type": "string", "description": "Enter the FULL URL of the user's website. Must begin with https://" }, "booking_link": { "type": "string", "description": "Enter the FULL URL of the user's booking page. Must begin with https://" }, "blog": { "type": "string", "description": "Enter the FULL URL of the user's blog. Must begin with https://" }, "facebook": { "type": "string", "description": "Enter the FULL URL of the user's Facebook account. Must begin with https://" }, "twitter": { "type": "string", "description": "Enter the FULL URL of the user's Twitter account. Must begin with https://" }, "linkedin": { "type": "string", "description": "Enter the FULL URL of the user's Linkedin account. Must begin with https://" }, "youtube": { "type": "string", "description": "Enter the FULL URL of the user's YouTube account. Must begin with https://" }, "instagram": { "type": "string", "description": "Enter the FULL URL of the user's Instagram account. Must begin with https://" }, "pinterest": { "type": "string", "description": "Enter the FULL URL of the user's Pinterest account. Must begin with https://" }, "tiktok": { "type": "string", "description": "Enter the FULL URL of the user's Tiktok account. Must begin with https://" }, "snapchat": { "type": "string", "description": "Enter the FULL URL of the user's Snapchat account. Must begin with https://" }, "whatsapp": { "type": "string", "description": "Enter the FULL URL of the user's Whatsapp account. Must begin with https://" }, "profile_photo": { "type": "string", "description": "Profile photo URL (member headshot). **Bare URL only — no `?query`, must end in `.jpg`/`.jpeg`/`.png`/`.webp`.** Query strings get baked into imported filenames and 404. Pair with `auto_image_import=1` to fetch externals into site storage." }, "logo": { "type": "string", "description": "Logo URL (brand/business mark). **Bare URL only — no `?query`, must end in `.jpg`/`.jpeg`/`.png`/`.webp`.** Query strings get baked into imported filenames and 404. Pair with `auto_image_import=1` to fetch externals into site storage." }, "cover_photo": { "type": "string", "description": "Cover photo URL. **LANDSCAPE only — never portrait/vertical; source via `https://www.pexels.com/search//?orientation=landscape`; bare URL, no `?query`, must end in `.jpg`/`.jpeg`/`.png`/`.webp` — see **Rule: Image URLs**.** Query strings get baked into imported filenames and 404. Pair with `auto_image_import=1` to fetch externals into site storage." }, "auto_image_import": { "type": "string", "enum": [ "1", "0" ], "description": "If YES, system will import user images and save them to your website. Processing may take several minutes after import.\\n\\nValid values:\\n 1 = Yes\\n 0 = No" }, "profession_name": { "type": "string", "description": "Alternative to `profession_id` - pass the top-level category NAME as a string. BD looks it up in `list_professions`.\n\n**Create vs update asymmetry (silent-failure trap):**\n\n- `createUser` -> unknown names auto-create (hardcoded)\n\n- `updateUser` -> unknown names **SILENTLY SKIPPED** unless `create_new_categories=1` is also passed. The write succeeds and returns success; the category just doesn't change. Always pass `create_new_categories=1` on update when supplying a `profession_name` that might not exist." }, "create_new_categories": { "type": "integer", "enum": [ 0, 1 ], "description": "Set `1` to auto-create unknown category/service names on `updateUser`. Without it, unknown names in `services`/`profession_name` are silently skipped. **Has no effect on `createUser`** (which always auto-creates, hardcoded).\n\nAuto-created sub-categories go under the member's current top-level category with `master_id=0`; deeper nesting requires a separate `createSubCategory` call." }, "last_login": { "type": "string", "description": "Timestamp of user's last login. Format: `YYYYMMDDHHmmss` in the site's timezone. BD silently truncates other formats, corrupting the value. BD updates on each login — omit unless backfilling historical data during import/migration." }, "filename": { "type": "string", "description": "Override BD's auto-generated URL slug. **Usually OMIT** - let BD derive. BD may REGENERATE the slug on future updates (when city/category/name change), silently overwriting your override. Only set if you must control the public URL and will re-set it after every future update." }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } } } }, "/api/v2/user/delete": { "delete": { "operationId": "deleteUser", "summary": "Delete a member/user", "tags": [ "Users" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "user_id" ], "properties": { "user_id": { "type": "integer" }, "delete_images": { "type": "integer", "enum": [ 1 ], "description": "Set to 1 to also delete user images" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a user record by ID. Destructive - cannot be undone via API.\n\n**Use when:** the member record truly must be purged (GDPR request, test cleanup, confirmed duplicate). For reversible removal prefer `updateUser` with `active=3` (Canceled) - the record stays queryable and can be reactivated. Use `delete_images=1` to also purge stored profile/cover/logo images.\n\n**Required:** `user_id`.\n\n**Parameter interactions:**\n\n- `delete_images=1` (optional) - also deletes the member's stored profile/cover/logo images from site storage\n\n**See also:** `updateUser` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/user/search": { "post": { "operationId": "searchUsers", "summary": "Search members/users", "description": "Full-text search across members with category, location, and sorting options.\n\n**Lean by default:** rows strip the same heavy nested buckets as `listUsers`. Pass `include_*=1` flags to restore specific nested fields.\n\n**Use when:** (1) mirroring the public member-search experience - embedding search results in an external app, building a custom search-results page, or letting users search BD from outside the site; (2) verifying what is publicly findable for a given keyword / category / location combo (SEO coverage audits, \"who shows up if a visitor searches X?\"); (3) keyword / partial-name / location / category lookup in general. For exact-field lookup (by email, by user_id, by phone, by any admin column) use `listUsers` + `property` / `property_value` - faster, more precise, and supports admin-only filters (join date, subscription status, meta fields) that this endpoint does not.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Enums:** `sort`: `reviews`, `name ASC`, `name DESC`, `last_name_asc`, `last_name_desc`.\n\n**Parameter interactions:**\n\n- `q` - keyword (matches first name, last name, company, about, search_description)\n\n- `pid` (category ID), `tid` (sub-category/service ID), `ttid` (sub-sub-category) - taxonomy filters, use IDs from `listTopCategories` / `listSubCategories`\n\n- `address` + `dynamic=1` - proximity/geographic filtering\n\n**See also:** `getUser` (single record by ID), `listUsers` (full enumeration).\n\n**Returns:** `{ status: \"success\", message: [...records] }`. Supports pagination fields when result set is large.\n\n**Profile URL:** every user record has a `filename` field. To get the full public profile URL, concatenate: `/`. The `filename` is the complete relative path (e.g., `united-states/monterey-park/doctor/harrison-hasanuddin-d-o`) - DO NOT prepend `/business/`, `/profile/`, `/member/`, or any other segment. BD's router resolves `filename` verbatim.\n\n", "tags": [ "Users" ], "requestBody": { "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "properties": { "q": { "type": "string", "description": "Search keyword" }, "pid": { "type": "integer", "description": "Category ID" }, "tid": { "type": "integer", "description": "Sub-category ID" }, "ttid": { "type": "integer", "description": "Sub-sub-category ID" }, "address": { "type": "string" }, "sort": { "type": "string", "enum": [ "reviews", "name ASC", "name DESC", "last_name_asc", "last_name_desc" ] }, "page": { "type": "integer" }, "limit": { "type": "integer" }, "dynamic": { "type": "integer", "enum": [ 1 ] }, "include_password": { "type": "integer", "description": "Opt in to return bcrypt `password` hash.", "enum": [ 0, 1 ] }, "include_subscription": { "type": "integer", "description": "Opt in to return full `subscription_schema`. Heavy — when set, `limit` is auto-capped at 25 for transport stability.", "enum": [ 0, 1 ] }, "include_clicks": { "type": "integer", "description": "Opt in to return `user_clicks_schema.clicks` array.", "enum": [ 0, 1 ] }, "include_photos": { "type": "integer", "description": "Opt in to return `photos_schema` array. Heavy — when set, `limit` is auto-capped at 25 for transport stability.", "enum": [ 0, 1 ] }, "include_transactions": { "type": "integer", "description": "Opt in to return full `transactions` array. Heavy — when set, `limit` is auto-capped at 25 for transport stability.", "enum": [ 0, 1 ] }, "include_profession": { "type": "integer", "description": "Opt in to return `profession_schema`.", "enum": [ 0, 1 ] }, "include_tags": { "type": "integer", "description": "Opt in to return `tags` array.", "enum": [ 0, 1 ] }, "include_services": { "type": "integer", "description": "Opt in to return `services_schema` array. Heavy — when set, `limit` is auto-capped at 25 for transport stability.", "enum": [ 0, 1 ] }, "include_seo_hidden": { "type": "integer", "description": "Opt in to return SEO-hidden meta fields.", "enum": [ 0, 1 ] }, "include_about": { "type": "integer", "description": "Opt in to return the `about_me` HTML bio. Heavy — when set, `limit` is auto-capped at 25 for transport stability.", "enum": [ 0, 1 ] }, "include_legacy_fields": { "type": "integer", "description": "Return image-import state on `photos_schema` rows: `original`, `resized`, `error`. Requires `include_photos=1`.", "enum": [ 0, 1 ] } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } } } }, "/api/v2/user/login": { "post": { "operationId": "loginUser", "summary": "Validate user credentials", "description": "Checks if email/password are valid. Does NOT return profile data - use getUser after.\n\n**Use when:** implementing SSO or a custom login flow against BD - you need to verify a member's email+password is valid WITHOUT starting a web session. Does NOT return profile data; follow with `getUser` or `listUsers` to fetch the authenticated member's record.\n\n**Required:** `email`, `password`.\n\n**Parameter interactions:**\n\n- Does NOT return profile data on success - follow with `getUser` using the verified email to retrieve the member record\n\n**Returns:** `{ status: \"success\"|\"error\", message: ... }` - BD's standard response envelope.\n\n", "tags": [ "Users" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "email", "password" ], "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } } } }, "/api/v2/user/transactions": { "post": { "operationId": "getUserTransactions", "summary": "Get member billing transactions (invoices)", "tags": [ "Users" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "properties": { "user_id": { "type": "integer", "description": "Member ID (the standard input). Pass this OR `client_id`." }, "client_id": { "type": "integer", "description": "WHMCS billing record ID. Power-user alternative to `user_id`. Pass this OR `user_id`." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch the billing transaction history (invoices) for a specific member. Read-only. Backed by the WHMCS billing integration.\n\n**Required:** exactly one of `user_id` (standard — the BD member ID) OR `client_id` (power-user — the WHMCS billing record ID stored on the user as `users_data.clientid`). Default to `user_id`; reach for `client_id` only when you already have one in hand and want to bypass the user lookup.\n\n**Empty result with misleading error:** if the member has no billing record yet (never enrolled in any paid plan), BD returns HTTP 400 + `{status:\"error\", message:\"user_id or client_id is required\"}` even though the identifier WAS sent. The error wording is BD's, not the wrapper's. Treat that exact error message on this endpoint as \"no billing data for this member\" rather than \"missing parameter\".\n\n**Use when:** you need to see a member's paid/unpaid invoices, payment methods, billing history, or reconcile billing status. Common reasons: answering a member's \"what did I pay for?\" question, exporting billing history, auditing revenue per member.\n\n**See also:** `getUserSubscriptions` (active/past membership plan signups - different resource from invoices), `getUser` (member profile).\n\n**Returns:** `{ status: \"success\", message: { total: , invoices: [{...invoice records}] } }`. Each invoice includes `id`, `invoicenum` (may be empty string), `date`, `duedate`, `datepaid`, `subtotal`, `credit`, `tax`, `total`, `status` (`Paid`, `Unpaid`, etc.), `paymentmethod`, `notes` (admin-facing; may contain internal comments - redact before surfacing to end users), and an `items` array with per-line `description`, `amount`, `type`. NOT a simple list of rows - the `message` is an object containing `invoices` as the array. **Unpaid invoices** have `datepaid: \"0000-00-00 00:00:00\"` (MariaDB zero-date sentinel) - do NOT parse as ISO-8601; check `status === 'Unpaid'` or `datepaid.startsWith('0000')` first. `subscription_details` may be `false` (literal boolean) when absent.\n\n**Reference support articles:**\n\n" } }, "/api/v2/user/subscriptions": { "post": { "operationId": "getUserSubscriptions", "summary": "Get member subscriptions (membership plan history)", "tags": [ "Users" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "properties": { "user_id": { "type": "integer", "description": "Member ID (the standard input). Pass this OR `client_id`." }, "client_id": { "type": "integer", "description": "WHMCS billing record ID. Power-user alternative to `user_id`. Pass this OR `user_id`." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch the subscription / membership-plan history for a specific member. Read-only. Backed by the WHMCS billing integration.\n\n**Required:** exactly one of `user_id` (standard — the BD member ID) OR `client_id` (power-user — the WHMCS billing record ID stored on the user as `users_data.clientid`). Default to `user_id`; reach for `client_id` only when you already have one in hand and want to bypass the user lookup.\n\n**Empty result with misleading error:** if the member has no billing record yet (never enrolled in any paid plan), BD returns HTTP 400 + `{status:\"error\", message:\"user_id or client_id is required\"}` even though the identifier WAS sent. The error wording is BD's, not the wrapper's. Treat that exact error message on this endpoint as \"no billing data for this member\" rather than \"missing parameter\".\n\n**Use when:** checking a member's current membership plan, their billing cycle (Monthly/Yearly), next due date, plan upgrade history, whether auto-renewal is on, or past canceled subscriptions.\n\n**See also:** `getUserTransactions` (invoice-level billing records - different resource), `getUser` (member profile - profile-level subscription references `subscription_id`), `listMembershipPlans` (all plan definitions on the site).\n\n**Returns:** `{ status: \"success\", message: { total: , subscriptions: [{...subscription records}] } }`. Each subscription includes `id`, `userid`, `packageid` (membership plan ID), `regdate`, `nextduedate`, `billingcycle` (e.g. `Monthly`, `Yearly`), `paymentmethod`, `amount`, `domainstatus` (`Active`, `Cancelled`, etc.), and related fields. NOT a simple list of rows - the `message` is an object containing `subscriptions` as the array.\n\n**Reference support articles:**\n\n" } }, "/api/v2/users_reviews/get": { "get": { "operationId": "listReviews", "summary": "List reviews", "tags": [ "Reviews" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/property" }, { "$ref": "#/components/parameters/property_value" }, { "$ref": "#/components/parameters/property_operator" }, { "name": "include_full_text", "in": "query", "description": "Opt in to return the full `review_description` body. Default is lean: bodies over 500 chars are truncated and tagged `review_description_truncated: true`. Set `1` when the agent needs full text (single-record inspection, exporting, keyword-in-body analysis).", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of review records. Read-only.\n\n**Lean by default:** `review_description` is truncated to the first 500 chars + `…` when longer. Truncated rows are tagged `review_description_truncated: true`. Pass `include_full_text=1` to restore the full body per call (use sparingly at high `limit` — review text is unbounded and can dominate payload).\n\n**Use when:** building moderation queues (filter `review_status=0` for Pending), exporting all reviews, running review-velocity reports, or paginating through every review on the site. For keyword-in-body matching, use `property=review_description property_operator=LIKE property_value=`. For a single known review use `getReview`.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**Enums:** `property_operator`: `=`, `LIKE`, `>`, `<`, `>=`, `<=`.\n\n**See also:** `getReview` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object (with `review_description` truncated by default; see **Rule: Lean read responses**).\n\n" } }, "/api/v2/users_reviews/get/{review_id}": { "get": { "operationId": "getReview", "summary": "Get a single review", "tags": [ "Reviews" ], "parameters": [ { "name": "review_id", "in": "path", "required": true, "schema": { "type": "integer" } }, { "name": "include_full_text", "in": "query", "description": "Opt in to return the full `review_description` body. Default is lean: bodies over 500 chars are truncated and tagged `review_description_truncated: true`. Set `1` to get the complete body for this specific review.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single review record. Read-only.\n\n**Lean by default:** `review_description` is truncated to the first 500 chars + `…` when longer (tagged `review_description_truncated: true`). Pass `include_full_text=1` to get the complete body. For a single-record inspection this is usually the right call.\n\n**Use when:** investigating one specific review (usually from a moderation notification or support ticket that includes the `review_id`). For bulk moderation use `listReviews` with `review_status` filter.\n\n**Required:** `review_id`.\n\n**See also:** `listReviews` (enumerate many; supports keyword filter via `property=review_description property_operator=LIKE`).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/users_reviews/create": { "post": { "operationId": "createReview", "summary": "Create a review", "tags": [ "Reviews" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "user_id", "review_email" ], "properties": { "user_id": { "type": "integer" }, "review_name": { "type": "string", "description": "Reviewer's display name. Strongly recommended - most BD themes render this on the profile next to the review." }, "review_email": { "type": "string", "description": "REQUIRED (server rejects with `The review email is required` when omitted, despite earlier docs that listed only `user_id` as required). Reviewer's email. Used for notification threading and duplicate-review detection." }, "review_title": { "type": "string" }, "review_description": { "type": "string" }, "rating_overall": { "type": "integer", "minimum": 1, "maximum": 5 }, "recommend": { "type": "integer", "enum": [ 0, 1 ] }, "review_status": { "type": "integer", "enum": [ 0, 2, 3, 4 ], "description": "Review status (integer). Authoritative values from BD admin:\n 0 = Pending (newly submitted, awaiting moderation - default for new reviews)\n 2 = Accepted (approved and visible on the member profile)\n 3 = Declined (rejected by admin - not publicly visible)\n 4 = Waiting for Admin (member pre-accepted, admin sign-off required)\nValue 1 is NOT a documented status - **but BD does NOT reject it. Passing `1` stores `\"1\"` verbatim with undefined render behavior.** Stick to the documented set. On create, default flow starts at 0." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new review record. Writes live data.\n\n**Use when:** importing legacy reviews from another platform, adding placeholder reviews for test data, or scripting review submissions from an external integration. Real member-submitted reviews come through the BD review form - only use this API when bypassing that form.\n\n**Required:** `user_id`, `review_email`.\n\n**Parameter interactions:**\n\n- `user_id` - the member being reviewed\n\n- `rating_overall`: integer 1-5 (higher = better)\n\n- `recommend`: `0`=No, `1`=Yes (shown as a thumbs-up recommendation flag)\n\n- `review_status` controls initial visibility - default flow is `0` Pending -> admin review\n\n**See also:** `updateReview` (modify existing).\n\n" } }, "/api/v2/users_reviews/update": { "put": { "operationId": "updateReview", "summary": "Update a review", "tags": [ "Reviews" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "review_id" ], "properties": { "review_id": { "type": "integer" }, "review_name": { "type": "string" }, "review_title": { "type": "string" }, "review_description": { "type": "string" }, "rating_overall": { "type": "integer" }, "recommend": { "type": "integer" }, "review_status": { "type": "integer", "enum": [ 0, 2, 3, 4 ], "description": "Review status (integer):\n\n- `0` = Pending (awaiting moderation)\n\n- `2` = Accepted (visible on profile)\n\n- `3` = Declined (rejected, not public)\n\n- `4` = Waiting for Admin (member pre-accepted, needs admin sign-off)\n\nValue `1` is NOT documented. BD does NOT reject it - stores `\"1\"` verbatim with undefined render behavior. Stick to documented values. Normal flow: `0` -> `2` (accepted) or `0` -> `3` (declined)." }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing review record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** moderating - change `review_status` (`0`=Pending -> `2`=Accepted to publish, `3`=Declined to reject, `4`=Waiting for Admin). Also used for admin corrections of typos in review text.\n\n**Required:** `review_id`.\n\n**Enums:** `review_status`: `0`=Pending, `2`=Accepted, `3`=Declined, `4`=Waiting for Admin.\n\n**See also:** `createReview` (add new), `deleteReview` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/users_reviews/delete": { "delete": { "operationId": "deleteReview", "summary": "Delete a review", "tags": [ "Reviews" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "review_id" ], "properties": { "review_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a review record by ID. Destructive - cannot be undone via API.\n\n**Use when:** the review content violates policy and must be purged (spam, abuse, PII). For \"hide without removing\" use `updateReview` with `review_status=3` (Declined) - preserves the audit trail.\n\n**Required:** `review_id`.\n\n**See also:** `updateReview` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/users_clicks/get": { "get": { "operationId": "listClicks", "summary": "List click records", "tags": [ "Clicks" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/property" }, { "$ref": "#/components/parameters/property_value" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of click records. Read-only.\n\n**Use when:** pulling click-tracking analytics for reports - profile views, phone reveals, website clicks, email clicks. Filter by `user_id` to see clicks for one member.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getClick` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/users_clicks/get/{click_id}": { "get": { "operationId": "getClick", "summary": "Get a single click record", "tags": [ "Clicks" ], "parameters": [ { "name": "click_id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single click record. Read-only.\n\n**Use when:** rare - drilling into a specific click record by `click_id`. Most click-analytics work happens via `listClicks` with filters.\n\n**Required:** `click_id`.\n\n**See also:** `listClicks` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/users_clicks/create": { "post": { "operationId": "createClick", "summary": "Create a click record", "tags": [ "Clicks" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "user_id", "click_type", "click_name", "click_from", "click_url" ], "properties": { "user_id": { "type": "integer" }, "click_type": { "type": "string", "description": "Free-form label for the click target. NOT an enum — BD doesn't normalize and site owners create custom labels via click-tracking widgets. Common BD-canonical values (mixed casing intentional, BD does NOT normalize): `View Post`, `Profile`, `Phone Number`, `website`, `Facebook`, `Contact Form`, `Search Results`, `booking_link`, `Instagram`, `Post Link`, `LinkedIn`, `YouTube`, `Blog`, `Twitter`, `Google`, `Pinterest`. Reuse the exact casing of the value you're tracking against — `Profile` and `profile` produce two distinct rows in analytics." }, "click_name": { "type": "string" }, "click_from": { "type": "string", "description": "Full URL of the page where the click originated (HTTP referer). Free-form URL string; empty allowed when no referer header is present (e.g. direct hits, bot traffic). NOT an enum." }, "click_url": { "type": "string" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new click record. Writes live data.\n\n**Use when:** replicating a click event from an external source (e.g., tracking clicks on a mirrored profile page on another domain). Usually not needed - BD auto-records clicks on its own surfaces.\n\n**Required:** `user_id`, `click_type`, `click_name`, `click_from`, `click_url`.\n\n**Parameter interactions:**\n\n- `user_id` - the member profile being tracked\n\n- `click_type` - `link` (external), `phone` (reveal), or `email` (reveal)\n\n- `click_from` - source surface: `profile_page` or `search_results`\n\n- `click_url` - the URL that was clicked\n\n**See also:** `updateClick` (modify existing).\n\n" } }, "/api/v2/users_clicks/update": { "put": { "operationId": "updateClick", "summary": "Update a click record", "tags": [ "Clicks" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "click_id" ], "properties": { "click_id": { "type": "integer" }, "click_type": { "type": "string" }, "click_name": { "type": "string" }, "click_url": { "type": "string" }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing click record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** correcting click metadata. Rare - click records are typically append-only analytics.\n\n**Required:** `click_id`.\n\n**See also:** `createClick` (add new), `deleteClick` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/users_clicks/delete": { "delete": { "operationId": "deleteClick", "summary": "Delete a click record", "tags": [ "Clicks" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "click_id" ], "properties": { "click_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a click record by ID. Destructive - cannot be undone via API.\n\n**Use when:** removing test or spam click records from analytics. Does NOT affect the member's click counter if the site displays one.\n\n**Required:** `click_id`.\n\n**See also:** `updateClick` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/leads/get": { "get": { "operationId": "listLeads", "summary": "List leads", "tags": [ "Leads" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/property" }, { "$ref": "#/components/parameters/property_value" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of lead records. Read-only.\n\n**Use when:** pulling the admin's lead inbox, generating lead reports, or iterating all leads to push into a CRM. For fetching one lead by ID use `getLead`.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getLead` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/leads/get/{lead_id}": { "get": { "operationId": "getLead", "summary": "Get a single lead", "tags": [ "Leads" ], "parameters": [ { "name": "lead_id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single lead record. Read-only.\n\n**Use when:** handling one lead - viewing its details after a lead-notification email, following up in a CRM integration, or confirming the lead exists before calling `matchLead`.\n\n**Required:** `lead_id`.\n\n**See also:** `listLeads` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/leads/create": { "post": { "operationId": "createLead", "summary": "Create a lead", "tags": [ "Leads" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "lead_name", "lead_email", "lead_phone", "lead_message", "lead_location", "top_id" ], "properties": { "lead_name": { "type": "string", "description": "Submitter's full name." }, "lead_email": { "type": "string", "description": "Submitter email address." }, "lead_phone": { "type": "string", "description": "Submitter phone number." }, "lead_message": { "type": "string", "description": "Submitter's detailed request — what they typed describing their needs." }, "lead_location": { "type": "string", "description": "Geocoded location string. BD computes the derived geocode columns (`lat`/`lng`/bounding box/`country_sn`/`adm_lvl_1_sn`/`location_type`) from this." }, "top_id": { "type": "integer", "description": "Top category ID. Discover via `listTopCategories`." }, "send_lead_email_notification": { "type": "integer", "enum": [ 0, 1 ], "description": "Set `1` to fire lead notification emails on create. Default `0` - API-created leads are silent.\n\n- With `auto_match=1` or `users_to_match` populated -> matched-member notification fires (if this flag = `1`)\n\n- Without a match step -> only admin notification fires (if configured)\n\n**For matched-member notifications:** combine this flag with a matching trigger (`auto_match=1` and/or `users_to_match`) - neither alone is sufficient." }, "auto_match": { "type": "integer", "enum": [ 0, 1 ], "description": "Set to `1` to run the match step inline during create (equivalent to calling `matchLead` immediately after). Default `0` = no match step fires. When combined with `users_to_match`, the match step still fires but the category/location/service-area matching ALGORITHM is bypassed — the lead routes ONLY to the specified members." }, "users_to_match": { "type": "string", "description": "**ADVANCED - OVERRIDES auto-match.** Comma-separated list of `user_id` values OR member email addresses (mixable) to route this lead to directly. When set, BD skips normal category/location/service-area auto-matching and routes ONLY to these members.\n\nExamples: `239` | `6099,6100` | `user1@example.com,user2@example.com` | `239,user1@example.com`.\n\nTypically paired with `auto_match=1` (triggers match inline) AND `send_lead_email_notification=1` (fires matched-member email - without the flag, matches record but no email sends)." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new lead record. Writes live data.\n\n**Use when:** importing leads from an external form, CSV, or web-scrape. Default is SILENT - no notification emails fire unless you pass `send_lead_email_notification=1`, and no member routing happens unless you pass `auto_match=1` (inline) or call `matchLead` afterward. `top_id` (category) is required - look it up via `listTopCategories`.\n\n**Required:** `lead_name`, `lead_email`, `lead_phone`, `lead_message`, `lead_location`, `top_id`.\n\n**Parameter interactions:**\n\n- `top_id` - category ID; discover via `listTopCategories`\n\n- All 6 required fields (`lead_name`, `lead_email`, `lead_phone`, `lead_message`, `lead_location`, `top_id`) must be supplied together\n\n- Response includes `lead_id` AND `token` - token may be needed for customer-facing URLs\n\n- After creating, call `matchLead` to trigger member notifications\n\n**See also:** `updateLead` (modify existing).\n\n**Operational rules (from BD support article 12000091106):**\n\n- `send_lead_email_notification=1` - activates lead email notifications to the site admin and/or matched members. Default is off: leads created via API are silent unless this flag is set. For the full auto-matching flow (finds members by category/location and emails them), call `matchLead` separately after creating the lead (or pass `auto_match=1` on this call to run inline).\n\n**Targeting specific members (override auto-match):** set `users_to_match` to a comma-separated list of member IDs or emails (e.g. `6099,6100` or `user1@example.com,user2@example.com`, mixed OK). This BYPASSES the normal category/location/service-area matching and routes the lead to ONLY those members. Typically paired with `auto_match=1` (to run the match step inline) and `send_lead_email_notification=1` (to fire the matched-member email). Common pattern when an external system already knows who should receive the lead.\n\n" } }, "/api/v2/leads/match": { "post": { "operationId": "matchLead", "summary": "Auto-match lead to members", "description": "Triggers automatic matching - system finds members matching category, location, and service area, then sends notification emails.\n\n**Use when:** you've just created a lead (or need to re-distribute an existing one) and want BD to automatically email eligible members in matching category + location + service area. SIDE EFFECT: sends real emails to real members. Confirm with the user before calling on production data.\n\n**Required:** `lead_id`.\n\n**Parameter interactions:**\n\n- Side effect: sends notification emails to ALL members whose category, location, and service area match the lead\n\n- Not a dry-run - emails go out immediately. Not rate-limited per lead\n\n- `lead_id` must reference an existing lead created via `createLead`\n\n**Returns:** `{ status: \"success\"|\"error\", message: ... }` - BD's standard response envelope.\n\n", "tags": [ "Leads" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "lead_id" ], "properties": { "lead_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } } } }, "/api/v2/leads/update": { "put": { "operationId": "updateLead", "summary": "Update a lead", "tags": [ "Leads" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "lead_id" ], "properties": { "lead_id": { "type": "integer" }, "lead_name": { "type": "string", "description": "Submitter's full name." }, "lead_email": { "type": "string", "description": "Submitter email address." }, "lead_phone": { "type": "string", "description": "Submitter phone number." }, "lead_message": { "type": "string", "description": "Submitter's detailed request — what they typed describing their needs." }, "lead_location": { "type": "string", "description": "Geocoded location string. BD recomputes the derived geocode columns (`lat`/`lng`/bounding box/`country_sn`/`adm_lvl_1_sn`/`location_type`) when this changes." }, "lead_more": { "type": "integer", "enum": [ 0, 1 ], "description": "Match-broadcast flag from the submitter's form input (radio/select). `0` = contact only the single matched member (default). `1` = broadcast to multiple matching members." }, "lead_price": { "type": "number", "description": "Lead price (decimal). `0.00` = free." }, "top_id": { "type": "integer", "description": "Top category ID. Changing this orphans existing `lead_matches` rows — re-run `matchLead` to re-route." }, "sub_id": { "type": "integer", "description": "Sub-category ID. Same `lead_matches` consideration as `top_id`." }, "lead_notes": { "type": "string", "description": "Owner-to-member notes about the lead (e.g. \"phone number verified\"). Visible to the matched member; never shown to the submitter. Leave blank unless the user asks." }, "lead_status": { "type": "integer", "enum": [ 1, 2, 4, 5, 6, 7, 8 ], "description": "Lead status (integer). NON-SEQUENTIAL enum - `3` does NOT exist; do not assume gaps are fillable:\n\n- `1` = Pending (received, awaiting action)\n\n- `2` = Matched (assigned to members)\n\n- `4` = Follow-Up (in progress)\n\n- `5` = Sold Out (no capacity)\n\n- `6` = Closed (resolved - converted or won't convert)\n\n- `7` = Bad Leads (spam/invalid)\n\n- `8` = Delete (soft-delete - hides from views)\n\n\"Sold\" / \"won\" -> `6` (Closed). Spam -> `7`. BD does NOT validate this enum - out-of-set integers are accepted and stored with undefined render behavior. Always use documented values." }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing lead record by ID. Fields omitted are untouched.\n\n**Required:** `lead_id`.\n\n**Custom (form-defined) lead fields** — e.g. wizard-style hidden `match_*` fields — live in `users_meta` with `database=leads` and `database_id=`. Use `updateUserMeta` / `createUserMeta` for those. **Rule: Forms** § Form classes → Custom-field storage covers the pattern.\n\n**See also:** `createLead`, `deleteLead`, `matchLead`, `updateUserMeta`.\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }`.\n\n" } }, "/api/v2/leads/delete": { "delete": { "operationId": "deleteLead", "summary": "Delete a lead", "tags": [ "Leads" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "lead_id" ], "properties": { "lead_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a lead record by ID. Destructive - cannot be undone via API.\n\n**Use when:** removing a spam or test lead. For preserving the lead but closing it, use `updateLead` with a status change instead.\n\n**Required:** `lead_id`.\n\n**See also:** `updateLead` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/lead_matches/get": { "get": { "operationId": "listLeadMatches", "summary": "List lead matches", "tags": [ "Lead Matches" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of lead_matches records. Read-only.\n\n**Use when:** auditing who got notified about which lead - useful for billing reports (paid-per-lead sites) or explaining to a member why they did/didn't receive a lead notification. Filter by `lead_id` to see all matches for one lead, or by `user_id` to see all leads a member received.\n\n**Empty-state quirk:** BD returns `{ status: \"error\", message: \"lead_matches not found\", total: 0 }` on zero rows (NOT the standard success-shape). The wrapper normalizes this to `{ status: \"success\", total: 0, message: [] }` before responding — but if a raw BD response leaks through, **treat the exact message `\"lead_matches not found\"` as an empty result, not as an endpoint failure.**\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`.\n\n" } }, "/api/v2/lead_matches/get/{match_id}": { "get": { "operationId": "getLeadMatch", "summary": "Get a single lead match", "tags": [ "Lead Matches" ], "parameters": [ { "name": "match_id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single leadmatch record. Read-only.\n\n**Use when:** you have a specific `match_id` (from `listLeadMatches`) and need the full match row - lead points, price, response status, etc.\n\n**Required:** `match_id`.\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/lead_matches/create": { "post": { "operationId": "createLeadMatch", "summary": "Create a lead match", "tags": [ "Lead Matches" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "lead_id", "user_id", "lead_status", "match_price", "lead_token", "lead_matched_by" ], "properties": { "lead_id": { "type": "integer" }, "user_id": { "type": "integer" }, "lead_status": { "type": "integer", "enum": [ 1, 2, 4, 5, 6, 7, 8 ], "description": "Lead status (integer). NON-SEQUENTIAL enum - `3` does NOT exist; do not assume gaps are fillable:\n\n- `1` = Pending (received, awaiting action)\n\n- `2` = Matched (assigned to members)\n\n- `4` = Follow-Up (in progress)\n\n- `5` = Sold Out (no capacity)\n\n- `6` = Closed (resolved - converted or won't convert)\n\n- `7` = Bad Leads (spam/invalid)\n\n- `8` = Delete (soft-delete - hides from views)\n\n\"Sold\" / \"won\" -> `6` (Closed). Spam -> `7`. BD does NOT validate this enum - out-of-set integers are accepted and stored with undefined render behavior. Always use documented values." }, "match_price": { "type": "number" }, "lead_token": { "type": "string" }, "lead_matched_by": { "type": "string" }, "lead_points": { "type": "integer" }, "lead_match_notes": { "type": "string" }, "lead_viewed": { "type": "integer" }, "lead_type": { "type": "string" }, "lead_distance": { "type": "number" }, "lead_rating": { "type": "number" }, "lead_chosen": { "type": "integer" }, "lead_response": { "type": "string" }, "lead_accepted": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new leadmatch record. Writes live data.\n\n**Use when:** manually creating a lead↔member match BYPASSING BD's auto-matching. Rarely needed - usually `matchLead` handles this automatically. Use for data migrations, manual override scenarios, or replaying matches from another system.\n\n**Required:** `lead_id`, `user_id`, `lead_status`, `match_price`, `lead_token`, `lead_matched_by`.\n\n**Pre-check before create (PAIR uniqueness):** BD does NOT enforce uniqueness on the `(lead_id, user_id)` pair. Matching the same lead to the same member twice creates two match rows - the member gets double-billed (if the match charges credits), both rows appear in the member's inbox, and reporting double-counts the match. **Filter-find pattern (single-field server filter + client-side intersect - the server does not yet honor array-syntax multi-condition filters):** call `listLeadMatches property=lead_id property_value= property_operator==` to narrow to all matches for that lead, then CLIENT-SIDE filter the returned rows to those where `user_id=`. Zero results after the client-side step = pair free; >=1 = already matched. If the pair already exists: reuse via `updateLeadMatch` (e.g. to bump `lead_status`), OR confirm with the user before creating the duplicate match. Never silently double-match.\n\n**Parameter interactions:**\n\n- Usually created automatically by `matchLead`; manual creation bypasses BD's matching logic\n\n- `lead_id` and `user_id` must both exist (use `getLead` / `getUser` to verify)\n\n- `lead_status` - match lifecycle state (see Enums)\n\n- `match_price`, `lead_points`, `lead_rating`, `lead_distance` - scoring fields used in ranking and billing\n\n**See also:** `updateLeadMatch` (modify existing).\n\n" } }, "/api/v2/lead_matches/update": { "put": { "operationId": "updateLeadMatch", "summary": "Update a lead match", "tags": [ "Lead Matches" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "match_id" ], "properties": { "match_id": { "type": "integer" }, "lead_status": { "type": "integer", "enum": [ 1, 2, 4, 5, 6, 7, 8 ], "description": "Lead status (integer). NON-SEQUENTIAL enum - `3` does NOT exist; do not assume gaps are fillable:\n\n- `1` = Pending (received, awaiting action)\n\n- `2` = Matched (assigned to members)\n\n- `4` = Follow-Up (in progress)\n\n- `5` = Sold Out (no capacity)\n\n- `6` = Closed (resolved - converted or won't convert)\n\n- `7` = Bad Leads (spam/invalid)\n\n- `8` = Delete (soft-delete - hides from views)\n\n\"Sold\" / \"won\" -> `6` (Closed). Spam -> `7`. BD does NOT validate this enum - out-of-set integers are accepted and stored with undefined render behavior. Always use documented values." }, "lead_response": { "type": "string" }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing leadmatch record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** recording a member's response to a lead (`lead_response`, `lead_accepted`, `lead_chosen`) or adjusting `lead_points`/`match_price` for billing reconciliation.\n\n**Required:** `match_id`.\n\n**Enums:** `lead_status`: `1`=Pending, `2`=Matched, `4`=Follow-Up, `5`=Sold Out, `6`=Closed, `7`=Bad Leads, `8`=Delete. (Verified against admin UI dropdown 2026-04-19. Value `3` does not exist. BD accepts out-of-range integers silently - stick to this set.)\n\n**See also:** `createLeadMatch` (add new), `deleteLeadMatch` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/lead_matches/delete": { "delete": { "operationId": "deleteLeadMatch", "summary": "Delete a lead match", "tags": [ "Lead Matches" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "match_id" ], "properties": { "match_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a leadmatch record by ID. Destructive - cannot be undone via API.\n\n**Use when:** cleaning up an erroneous match (e.g., test data) or removing a match that was auto-created but shouldn't exist. Does NOT unsend the notification email that may have already fired.\n\n**Required:** `match_id`.\n\n**See also:** `updateLeadMatch` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/data_posts/get": { "get": { "operationId": "listSingleImagePosts", "summary": "List posts", "tags": [ "Posts" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/property" }, { "$ref": "#/components/parameters/property_value" }, { "$ref": "#/components/parameters/include_content" }, { "$ref": "#/components/parameters/include_post_seo" }, { "$ref": "#/components/parameters/include_author_full" }, { "$ref": "#/components/parameters/include_clicks" }, { "$ref": "#/components/parameters/include_photos" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of post records. Read-only.\n\n**Lean by default:** each row strips `post_content` (HTML body), `post_meta_*` (SEO bundle), the full nested `user` author object, the full nested `data_category` post-type config, `user_clicks_schema.clicks` array, `list_service`, and ~25 admin-form residue fields. Post rows always include `data_id`, `data_type`, `system_name`, `data_name`, `data_filename`, `form_name` for post-type routing - full post-type config (sidebars, code fields, search modules, h1/h2, timestamps) is NOT returned on post reads; call `getPostType` with `data_id` if you need it. Replaces full `user` with `author: {...}` summary (`user_id`, `first_name`, `last_name`, `company`, `email`, `phone_number`, `filename`, `image_main_file`, `subscription_id`, `active`). Replaces click array with `total_clicks: N`. Use `include_*` flags to restore specific nested fields.\n\n**Use when:** enumerating posts of single-image families - blog articles, events, jobs, coupons, videos, discussions. Filter by `user_id` for one member's posts, or `data_id` to scope to one post type. Before using, confirm the target post type has `data_type` 9 or 20 (single-image); `data_type=4` means multi-image and you want `listMultiImagePosts` instead.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getSingleImagePost` (single record by ID). For keyword-in-body matching, use this tool with `property=post_title property_operator=LIKE` (or `post_caption`/`post_content`).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/data_posts/get/{post_id}": { "get": { "operationId": "getSingleImagePost", "summary": "Get a single post", "tags": [ "Posts" ], "parameters": [ { "name": "post_id", "in": "path", "required": true, "schema": { "type": "integer" } }, { "$ref": "#/components/parameters/include_content" }, { "$ref": "#/components/parameters/include_post_seo" }, { "$ref": "#/components/parameters/include_author_full" }, { "$ref": "#/components/parameters/include_clicks" }, { "$ref": "#/components/parameters/include_photos" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single post record. Read-only.\n\n**Lean by default:** response strips the same heavy nested buckets as `listSingleImagePosts` (see **Rule: Lean read responses**). Pass `include_*=1` flags to restore specific nested fields.\n\n**Use when:** fetching one post by `post_id`. For enumeration or keyword search use `listSingleImagePosts` (with `property=post_title property_operator=LIKE` for keyword-in-body).\n\n**Required:** `post_id`.\n\n**See also:** `listSingleImagePosts` (enumerate many; supports keyword filter via `property` + `property_operator=LIKE`).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/data_posts/fields": { "get": { "operationId": "getSingleImagePostFields", "summary": "Get post field definitions", "description": "Returns custom fields for a specific post type form.\n\n**Use when:** discovering the per-post-type custom fields before building a create/update payload. Pass `form_name` of the target post type (e.g. `blog_article_fields`, `events_fields`, etc.).\n\n**Required:** `form_name`.\n\n**Returns:** a BARE ARRAY of field-definition objects (NOT wrapped in `{status, message}`). Each entry has `key`, `label`, `required`, `type`, and optionally `choices` (for dropdown/radio), `default`, `helpText`.\n\n**Silent-fallback warning:** if `form_name` does NOT match a real post-type form, BD returns HTTP 200 with a generic SUPER-UNION field list (containing every possible post field including `post_location`, `lat`, `lon`, `post_live_date`, `post_video`, `post_job`, internals like `post_type`/`logged_user`/`form_security_token`) - and `post_category.choices` will be ABSENT. Always verify `form_name` exists first via `listPostTypes` (look for the matching row's `form_name` column). If your response has `post_category` WITHOUT a `choices` array, you hit the fallback.\n\n**Discovering `post_category` dropdown values:** `post_category` is a per-post-type dropdown whose options come from the post type's `feature_categories` CSV (admin-managed). Read `post_category.choices[].key` for the exact values to send. BD does NOT trim whitespace when splitting the CSV - options after the first may have a leading space (e.g. `\" Category 2\"`). Pass VERBATIM.\n\n", "tags": [ "Posts" ], "parameters": [ { "name": "form_name", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Form slug for the post type" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "type": "object" } } } } } } }, "/api/v2/data_posts/create": { "post": { "operationId": "createSingleImagePost", "summary": "Create a post", "tags": [ "Posts" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "user_id", "data_id", "data_type" ], "properties": { "user_id": { "type": "integer" }, "data_id": { "type": "integer", "description": "Parent post-type ID (data_categories.data_id, from listPostTypes)." }, "data_type": { "type": "integer", "description": "Classification family, read from target post type's `data_type` column (via `listPostTypes`/`getPostType`). Values:\n\n- `4` = multi-image - use `createMultiImagePost` instead\n\n- `9` = single-image video\n\n- `20` = single-image article/event/job/coupon\n\nInternal-only values (`10`, `13`, `21`, `28`, `29`) are NOT post-creatable via this endpoint - use the resource-specific creator (e.g. `createReview` for `13`). Do NOT call `listDataTypes` - `data_type` is a classification, not a per-site FK." }, "post_title": { "type": "string" }, "post_caption": { "type": "string", "description": "Deprecated. Leave unset unless user explicitly references it." }, "post_content": { "type": "string", "description": "Main HTML body of the post. Froala body field — see **Rule: Post-body formatting** (structure, `fr-dib fr-fil`/`fr-fir` float + inline `width: 350px`, landscape Pexels images). HTML allowed; supports `[widget=Name]` shortcodes and `%%%template_tokens%%%`." }, "post_status": { "type": "integer", "enum": [ 0, 1, 3 ], "description": "0=Not Published, 1=Published, 3=Pending Approval (rare — set when site admin requires manual moderation before posts go live)." }, "post_price": { "type": "number" }, "post_image": { "type": "string", "description": "Feature image URL. **LANDSCAPE only — never portrait/vertical; source via `https://www.pexels.com/search//?orientation=landscape`; bare URL, no `?query`, must end in `.jpg`/`.jpeg`/`.png`/`.webp` — see **Rule: Image URLs**.** Query strings (`?w=1600`, `?auto=compress`) get baked into the imported filename and 404. Default: Pexels landscape URL + `auto_image_import=1` (skip only on explicit no-image request)." }, "post_category": { "type": "string", "description": "Per-post-type dropdown value, configured in BD admin on the post type's `feature_categories` field. Discover allowed values via `getSingleImagePostFields(form_name=)` -> `post_category.choices[].key`.\n\nPass VERBATIM - BD does not trim whitespace, so leading spaces after commas in `feature_categories` persist in the stored option values." }, "post_tags": { "type": "string", "description": "Comma-separated keywords for the post. Free-form strings - not related to the `Tags` resource." }, "post_location": { "type": "string", "description": "Full or partial street address for the post (Event, Coupon, Job, or any geo-enabled post type). Pair with `auto_geocode=1` to resolve to lat/lon automatically, or set `lat`/`lon`/`state_sn`/`country_sn` explicitly." }, "lat": { "type": "string", "description": "Latitude for the post's location (geo-enabled post types). Decimal degrees as string." }, "lon": { "type": "string", "description": "Longitude for the post's location (geo-enabled post types). Decimal degrees as string." }, "state_sn": { "type": "string", "description": "2-letter state/region code for the post's location (e.g. `CA`)." }, "country_sn": { "type": "string", "description": "2-letter country code for the post's location (e.g. `US`)." }, "post_live_date": { "type": "string", "description": "Creation date stored on the post. Format: `YYYYMMDDHHmmss` in the site's timezone. BD silently truncates other formats, corrupting the value. Usually auto-set on create; override only for import/migration." }, "post_start_date": { "type": "string", "description": "Scheduled publish date — when the post becomes visible on the public site. Set a future timestamp to schedule (like WordPress's future-publish); set a past timestamp for immediate visibility. REQUIRED on Event post types (marks when the event begins); optional but commonly used on blog/article/news post types for scheduled publishing. Format: `YYYYMMDDHHmmss` in the site's timezone. BD silently truncates other formats, corrupting the value." }, "post_expire_date": { "type": "string", "description": "End/expiration date. Coupon post types use this for expiration; Event post types use it for end time. Format: `YYYYMMDDHHmmss` in the site's timezone. BD silently truncates other formats, corrupting the value." }, "post_video": { "type": "string", "description": "Full URL of a YouTube or Vimeo video. Only used by Video post types (`data_type=9`)." }, "post_job": { "type": "string", "enum": [ "Full-Time", "Part-Time", "Freelance", "Internship", "Consultant", "Contract" ], "description": "Employment type - used by Job post types only. Other post types ignore this field." }, "auto_geocode": { "type": "integer", "enum": [ 0, 1 ], "description": "Set to `1` to geocode the post's location via Google Maps (uses `post_location`/lat/lon/state_sn/country_sn if supplied). Requires the \"Pretty URLs with Google Maps\" site feature." }, "post_meta_title": { "type": "string", "description": "SEO `` override for the post's public page." }, "post_meta_description": { "type": "string", "description": "SEO meta description override for the post's public page." }, "post_meta_keywords": { "type": "string", "description": "SEO meta keywords (comma-separated) for the post's public page." }, "auto_image_import": { "type": "integer", "enum": [ 0, 1 ], "description": "**Auto-import images to site storage.** Set `1` when any external image URL field on this single-image post (e.g. `post_image`) holds a URL - BD fetches the image and saves locally. Without the flag, BD stores the URL as-is; images break if source host goes down.\n\n**Recommended default when supplying external image URLs**; omit or set `0` only if user explicitly wants the external URL reference. Supports JPG/PNG/GIF/WebP/SVG. Processing delay: several minutes." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new post record. Writes live data.\n\n**Use when:** creating a blog article, event, job listing, coupon, or any other single-image post type. Look up `data_id` + `data_type` via `listPostTypes` first - the post type's `data_type` field determines which create endpoint is correct. If `data_type=4` on the post type, use `createMultiImagePost` instead. For posts with scraped external image URLs, include `auto_image_import=1` to fetch and store them locally.\n\n**Required:** `user_id`, `data_id`, `data_type`.\n\n**Pre-check before create:** BD does NOT enforce uniqueness on `post_title`, and BD auto-generates `filename` (the URL slug) from the title - so a duplicate title produces a URL collision (two posts fighting for the same public URL, unpredictable which one resolves). Do a **server-side filter-find**: `listSingleImagePosts property=post_title property_value=<proposed> property_operator==`. Zero rows = title free; >=1 row = taken. **Do NOT paginate unfiltered lists** - sites in the wild have thousands of posts; filtered lookup is one tiny response. If taken: reuse via `updateSingleImagePost`, OR ask the user, OR pick an alternate `post_title` and re-check. Never silently create a duplicate.\n\n**Parameter interactions:**\n\n- `user_id` - owner; must be an existing member (discover via `listUsers` or `searchUsers`)\n\n- `data_id` - post type category ID; get via `listPostTypes`\n\n- `data_type` - data type classification; usually matches the post type's data type\n\n- `post_status`: `0`=Draft (not visible), `1`=Published (public)\n\n- Response includes both `post_id` and `post_token` - the token is used for sharable URLs\n\n**See also:** `updateSingleImagePost` (modify existing).\n\n---\n\n**Which endpoint to use - `data_type` family decides:**\n\nEvery post type in `data_categories` has a `data_type` field that classifies its family. Call `listPostTypes` or `getPostType` to see the `data_type` of your target post type, then choose:\n\n| `data_type` value | Family | Use endpoint |\n|---|---|---|\n| `4` | Multi-image (albums, galleries, photo-heavy listings - e.g. Classified, Photo Album, Property, Product) | `createMultiImagePost` |\n| `9` | Single-image video | `createSingleImagePost` |\n| `20` | Single-image article / event / blog / job / coupon | `createSingleImagePost` |\n| `10`, `13`, `21`, `28`, `29` (and similar) | Internal admin types (Member Listings, Reviews, Sub Accounts, Specialties, Favorites) - NOT posts | Use the resource-specific endpoint (e.g. `createReview` for `data_type=13`) |\n\nIf you call the wrong create endpoint for a given post type, BD may accept the row but it won't render on the public site correctly.\n\n**For \"make a blog post\" intent:** look up `data_categories` for `data_name` matching \"blog\" (commonly `data_id=14` with `data_type=20`) -> `createSingleImagePost` with that `data_id` + `data_type`.\n\n**For \"make a photo album\" / \"gallery\" intent:** look up the album post type (often `data_id=10`, `data_type=4`) -> `createMultiImagePost` with that `data_id` + `data_type`. Photos are added separately via `createMultiImagePostPhoto` using the returned `group_id`.\n\n**Picking `post_category` (and other per-type dropdowns):** `post_category` values are configured PER POST TYPE by the site admin in the post type's `feature_categories` CSV. Before create, call `getSingleImagePostFields(form_name=<post type's form_name>)` and read `post_category.choices[].key`. Pass ONLY values from that list, VERBATIM - BD does not trim whitespace when splitting `feature_categories`, so options after the first may have a leading space (e.g. `\" Category 2\"`). If the user names a category that isn't in the list: ask whether to pick the closest existing option or have them add the new option in BD admin first - do NOT invent a new value. WARNING: if `form_name` does not match a real post type form, `getSingleImagePostFields` silently returns a generic SUPER-UNION field list (HTTP 200, no error) - verify `form_name` exists in `listPostTypes` first.\n\n" } }, "/api/v2/data_posts/update": { "put": { "operationId": "updateSingleImagePost", "summary": "Update a post", "tags": [ "Posts" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "post_id" ], "properties": { "post_id": { "type": "integer" }, "post_title": { "type": "string" }, "post_filename": { "type": "string", "description": "Public URL slug path (e.g. `blog/my-post-slug`). Writable — BD does NOT regenerate the slug when `post_title` changes. See **Rule: URL slug rename** for when to suggest a slug update + redirect." }, "post_content": { "type": "string", "description": "Post body HTML. Froala body field — see **Rule: Post-body formatting** (structure, `fr-dib fr-fil`/`fr-fir` float + inline `width: 350px`, landscape Pexels images)." }, "post_status": { "type": "integer", "enum": [ 0, 1, 3 ], "description": "0=Not Published, 1=Published, 3=Pending Approval (rare — set when site admin requires manual moderation before posts go live)." }, "post_caption": { "type": "string", "description": "Deprecated. Leave unset unless user explicitly references it." }, "post_price": { "type": "number" }, "post_image": { "type": "string", "description": "Feature image URL. **LANDSCAPE only — never portrait/vertical; source via `https://www.pexels.com/search/<term>/?orientation=landscape`; bare URL, no `?query`, must end in `.jpg`/`.jpeg`/`.png`/`.webp` — see **Rule: Image URLs**.** Query strings (`?w=1600`, `?auto=compress`) get baked into the imported filename and 404. Pair with `auto_image_import=1` to fetch externals into site storage." }, "post_category": { "type": "string", "description": "Per-post-type dropdown value, configured in BD admin on the post type's `feature_categories` field. Discover allowed values via `getSingleImagePostFields(form_name=<post type's form_name>)` -> `post_category.choices[].key`.\n\nPass VERBATIM - BD does not trim whitespace, so leading spaces after commas in `feature_categories` persist in the stored option values." }, "post_tags": { "type": "string", "description": "Comma-separated keywords for the post." }, "auto_geocode": { "type": "integer", "enum": [ 0, 1 ], "description": "Set to `1` to geocode the post's location. Requires the \"Pretty URLs with Google Maps\" site feature." }, "post_meta_title": { "type": "string" }, "post_meta_description": { "type": "string" }, "post_meta_keywords": { "type": "string" }, "post_location": { "type": "string", "description": "Full or partial street address (Event/Coupon/Job/geo-enabled post types)." }, "lat": { "type": "string" }, "lon": { "type": "string" }, "state_sn": { "type": "string" }, "country_sn": { "type": "string" }, "post_live_date": { "type": "string", "description": "Creation date stored on the post. Format: `YYYYMMDDHHmmss` in the site's timezone. BD silently truncates other formats, corrupting the value." }, "post_start_date": { "type": "string", "description": "Scheduled publish date — when the post becomes visible on the public site. Set a future timestamp to schedule (like WordPress's future-publish); set a past timestamp for immediate visibility. REQUIRED on Event post types (marks when the event begins); optional but commonly used on blog/article/news post types for scheduled publishing. Format: `YYYYMMDDHHmmss` in the site's timezone. BD silently truncates other formats, corrupting the value." }, "post_expire_date": { "type": "string", "description": "End/expiration date. Coupon post types use this for expiration; Event post types use it for end time. Format: `YYYYMMDDHHmmss` in the site's timezone. BD silently truncates other formats, corrupting the value." }, "post_video": { "type": "string", "description": "YouTube/Vimeo URL - Video post types only." }, "post_job": { "type": "string", "enum": [ "Full-Time", "Part-Time", "Freelance", "Internship", "Consultant", "Contract" ], "description": "Employment type - Job post types only." }, "auto_image_import": { "type": "integer", "enum": [ 0, 1 ], "description": "**Auto-import images to site storage.** Set `1` when any external image URL field on this single-image post (e.g. `post_image`) holds a URL - BD fetches the image and saves locally. Without the flag, BD stores the URL as-is; images break if source host goes down.\n\n**Recommended default when supplying external image URLs**; omit or set `0` only if user explicitly wants the external URL reference. Supports JPG/PNG/GIF/WebP/SVG. Processing delay: several minutes." }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing post record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** editing post content, switching from draft to published (`post_status=0`->`1`), updating post title/caption, or correcting post metadata. To move a post to a different post type (rare), pass `data_id` - but validate the new post type is still in the single-image family.\n\n**Required:** `post_id`.\n\n**Enums:** `post_status`: `0`=Draft (saved but not publicly visible), `1`=Published (publicly visible on the site).\n\n**`post_title` rename does NOT update `post_filename` (the URL slug).** `post_filename` is writable — see **Rule: URL slug rename** for when to suggest a slug update + redirect. Report `post_filename` from `getSingleImagePost` when giving the user a URL.\n\n**See also:** `createSingleImagePost` (add new), `deleteSingleImagePost` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/data_posts/delete": { "delete": { "operationId": "deleteSingleImagePost", "summary": "Delete a post", "tags": [ "Posts" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "post_id" ], "properties": { "post_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a post record by ID. Destructive - cannot be undone via API.\n\n**Use when:** removing a post permanently. For \"hide without deleting\" use `updateSingleImagePost` with `post_status=0` (Draft). Deleting also removes the post_token, breaking any external links to the share URL.\n\n**Required:** `post_id`.\n\n**See also:** `updateSingleImagePost` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/users_portfolio_groups/get": { "get": { "operationId": "listMultiImagePosts", "summary": "List album groups", "tags": [ "Portfolio Groups" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/property" }, { "$ref": "#/components/parameters/property_value" }, { "$ref": "#/components/parameters/include_content" }, { "$ref": "#/components/parameters/include_author_full" }, { "$ref": "#/components/parameters/include_clicks" }, { "$ref": "#/components/parameters/include_photos" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of portfoliogroup records. Read-only.\n\n**Lean by default:** each row strips `group_desc` (HTML body, field name differs from Single posts), nested `user` author object, nested `data_category`, `user_clicks_schema.clicks`, and nested `users_portfolio` photo array (keeps `cover_photo_url`, `cover_thumbnail_url`, `total_photos` summary from the first photo). Post rows always include `data_id`, `data_type`, `system_name`, `data_name`, `data_filename`, `form_name` for post-type routing - full post-type config is NOT returned; call `getPostType` with `data_id` if you need it. Strips ~25 admin-form residue fields. Replaces `user` with `author: {...}` summary. Replaces clicks with `total_clicks: N`. Use `include_*` flags to restore anything stripped.\n\n**Use when:** enumerating photo-album / gallery-style posts (Photo Album, Classified, Property, Product - any post type with `data_type=4`). For single-image post types use `listSingleImagePosts`.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getMultiImagePost` (single record by ID). For keyword-in-body matching, use this tool with `property=group_name property_operator=LIKE` (or `group_desc`).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/users_portfolio_groups/get/{group_id}": { "get": { "operationId": "getMultiImagePost", "summary": "Get a single album group", "tags": [ "Portfolio Groups" ], "parameters": [ { "name": "group_id", "in": "path", "required": true, "schema": { "type": "integer" } }, { "$ref": "#/components/parameters/include_content" }, { "$ref": "#/components/parameters/include_author_full" }, { "$ref": "#/components/parameters/include_clicks" }, { "$ref": "#/components/parameters/include_photos" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single portfoliogroup record. Read-only.\n\n**Lean by default:** response strips the same heavy nested buckets as `listMultiImagePosts` (see **Rule: Lean read responses**). Pass `include_*=1` flags to restore specific nested fields.\n\n**Use when:** fetching one multi-image post by `group_id`. Photos in this post are loaded separately via `listMultiImagePostPhotos` with `group_id` filter.\n\n**Required:** `group_id`.\n\n**See also:** `listMultiImagePosts` (enumerate many; supports keyword filter via `property` + `property_operator=LIKE`).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/users_portfolio_groups/fields": { "get": { "operationId": "getMultiImagePostFields", "summary": "Get album group field definitions", "tags": [ "Portfolio Groups" ], "parameters": [ { "name": "form_name", "in": "query", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "type": "object" } } } } }, "description": "Fetch field definitions for a multi-image post type form. Read-only.\n\n**Use when:** discovering per-post-type custom fields for multi-image posts - same pattern as `getSingleImagePostFields` but for the `users_portfolio_groups` resource.\n\n**Required:** `form_name`.\n\n**Returns:** a BARE ARRAY of field-definition objects (NOT wrapped in `{status, message}`). Each entry has `key`, `label`, `required`, `type`, and optionally `choices`, `default`, `helpText`. Multi-image post fields seen: `user_id`, `group_status`, `group_name`, `group_desc`, `post_image` (CSV of image URLs), `auto_image_import`, `post_tags`, `auto_geocode`. Categorization for multi-image posts is exposed under an internal widget-controller field name, not a clean `post_category` - not straightforward to write via API.\n\n**Silent-fallback warning:** if `form_name` does NOT match a real post-type form, BD may return a generic field list without error. Verify `form_name` exists in `listPostTypes` before trusting the response.\n\n" } }, "/api/v2/users_portfolio_groups/create": { "post": { "operationId": "createMultiImagePost", "summary": "Create an album group", "tags": [ "Portfolio Groups" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "user_id", "data_id", "data_type" ], "properties": { "user_id": { "type": "integer" }, "data_id": { "type": "integer" }, "data_type": { "type": "integer", "description": "Classification family - for this endpoint, must be `4` (multi-image). Read from the target post type's `data_type` column via `listPostTypes` / `getPostType`. If the post type's `data_type` is `9` or `20`, use `createSingleImagePost` instead. Do NOT call `listDataTypes` - `data_type` is a classification, not a per-site FK." }, "post_image": { "type": "string", "description": "Comma-separated list of image URLs to import as the album's photos (created at upload time as child `MultiImagePostPhoto` records). **LANDSCAPE only — never portrait/vertical; source via `https://www.pexels.com/search/<term>/?orientation=landscape`; bare URLs, no `?query`, each must end in `.jpg`/`.jpeg`/`.png`/`.webp` — see **Rule: Image URLs**.** Query strings (`?w=1600`, `?auto=compress`) get baked into imported filenames and 404. Pair with `auto_image_import=1` to fetch externals into site storage. After create, verify every URL landed via `listMultiImagePostPhotos`. `createMultiImagePostPhoto` does NOT import externals and cannot patch a failed row." }, "post_tags": { "type": "string", "description": "Comma-separated keywords for the album. Free-form strings - not related to the `Tags` resource." }, "auto_geocode": { "type": "integer", "enum": [ 0, 1 ], "description": "Set to `1` to geocode the album's location. Requires the \"Pretty URLs with Google Maps\" site feature." }, "group_name": { "type": "string" }, "group_desc": { "type": "string", "description": "Album description HTML. Froala body field — see **Rule: Post-body formatting** (structure, `fr-dib fr-fil`/`fr-fir` float + inline `width: 350px`, landscape Pexels images)." }, "group_status": { "type": "integer", "enum": [ 0, 1, 3 ], "description": "0=Not Published, 1=Published, 3=Pending Approval (rare — set when site admin requires manual moderation before albums go live)." }, "auto_image_import": { "type": "integer", "enum": [ 0, 1 ], "description": "**Auto-import images to site storage.** Set `1` when any external image URL field on this multi-image post holds a URL - BD fetches and saves each image locally. Without the flag, BD stores URLs as-is; images break if source hosts go down.\n\n**Recommended default when supplying external image URLs**; omit or set `0` only if user explicitly wants external URL references. Supports JPG/PNG/GIF/WebP/SVG. Processing delay: several minutes per image." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new portfoliogroup record. Writes live data.\n\n**Use when:** creating a photo album, gallery, product listing with multiple photos, or any post type with `data_type=4`. Confirm the target post type's `data_type` via `listPostTypes` first - `data_type=4` belongs here; 9/20 belongs in `createSingleImagePost`. **For external image URLs, always use the bulk `post_image` CSV + `auto_image_import=1` here** - this is the only path that imports externals into site storage. `createMultiImagePostPhoto` does NOT import and is only suitable for already-hosted-on-site URLs.\n\n**Required:** `user_id`, `data_id`, `data_type`.\n\n**Pre-check before create:** BD does NOT enforce uniqueness on `group_name`, and the public URL slug is derived from it — duplicate names produce a URL collision (unpredictable which resolves). Do a **server-side filter-find**: `listMultiImagePosts property=group_name property_value=<proposed> property_operator==`. Zero rows = name free; >=1 = taken. If taken: reuse via `updateMultiImagePost`, ask the user, or pick an alternate. Never silently create a duplicate.\n\n**Parameter interactions:**\n\n- `data_id` + `data_type` - specify the post type this album belongs to (from `listPostTypes`; `data_type` must be `4`)\n\n- `group_status`: `0`=Hidden, `1`=Published\n\n- `post_image` - comma-separated image URLs imported as child photos at create time\n\n- `auto_image_import=1` - fetches the `post_image` URLs into site storage (required for external sources to survive)\n\n**Post-create verification (critical):** HTTP 200 does NOT mean every photo imported. After create, call `listMultiImagePostPhotos property=group_id&property_value=<new_group_id>&property_operator==`; row count must equal CSV count and every row needs non-empty `file` + `image_imported=2` (success; `0` = silent-failure row). Fix failed row: `deleteMultiImagePostPhoto`, then `updateMultiImagePost group_id=<same>&post_image=<replacement>&auto_image_import=1` (appends). Do NOT delete and recreate the album.\n\n**See also:** `updateMultiImagePost` (modify existing), `createMultiImagePostPhoto` (already-hosted URLs only).\n\n**Returns:** `{ status: \"success\", message: {...createdRecord} }` - includes the server-assigned `group_id`.\n\n---\n\n**Which endpoint to use - `data_type` family decides:**\n\nEvery post type in `data_categories` has a `data_type` field that classifies its family. Call `listPostTypes` or `getPostType` to see the `data_type` of your target post type, then choose:\n\n| `data_type` value | Family | Use endpoint |\n|---|---|---|\n| `4` | Multi-image (albums, galleries, photo-heavy listings - e.g. Classified, Photo Album, Property, Product) | `createMultiImagePost` |\n| `9` | Single-image video | `createSingleImagePost` |\n| `20` | Single-image article / event / blog / job / coupon | `createSingleImagePost` |\n| `10`, `13`, `21`, `28`, `29` (and similar) | Internal admin types (Member Listings, Reviews, Sub Accounts, Specialties, Favorites) - NOT posts | Use the resource-specific endpoint (e.g. `createReview` for `data_type=13`) |\n\nIf you call the wrong create endpoint for a given post type, BD may accept the row but it won't render on the public site correctly.\n\n**Category for multi-image posts:** multi-image posts do NOT expose `post_category` like single-image posts do. Album-level categorization is configured differently in BD admin and is not cleanly writable via the create payload. If categorization is needed, add it via a follow-up `updateMultiImagePost` or BD admin.\n\n" } }, "/api/v2/users_portfolio_groups/update": { "put": { "operationId": "updateMultiImagePost", "summary": "Update an album group", "tags": [ "Portfolio Groups" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "group_id" ], "properties": { "group_id": { "type": "integer" }, "group_name": { "type": "string" }, "group_filename": { "type": "string", "description": "Public URL slug path (e.g. `photo-albums/my-slug`). Writable — BD does NOT regenerate the slug when `group_name` changes. See **Rule: URL slug rename** for when to suggest a slug update + redirect." }, "group_desc": { "type": "string", "description": "Album description HTML. Froala body field — see **Rule: Post-body formatting** (structure, `fr-dib fr-fil`/`fr-fir` float + inline `width: 350px`, landscape Pexels images)." }, "group_status": { "type": "integer", "enum": [ 0, 1, 3 ], "description": "0=Not Published, 1=Published, 3=Pending Approval (rare — set when site admin requires manual moderation before albums go live)." }, "post_tags": { "type": "string", "description": "Comma-separated keywords for the album." }, "auto_geocode": { "type": "integer", "enum": [ 0, 1 ] }, "auto_image_import": { "type": "integer", "enum": [ 0, 1 ], "description": "**Auto-import images to site storage.** Set `1` when any external image URL field on this multi-image post holds a URL - BD fetches and saves each image locally. Without the flag, BD stores URLs as-is; images break if source hosts go down.\n\n**Recommended default when supplying external image URLs**; omit or set `0` only if user explicitly wants external URL references. Supports JPG/PNG/GIF/WebP/SVG. Processing delay: several minutes per image." }, "post_image": { "type": "string", "description": "Comma-separated image URLs — APPENDS new photos (does NOT replace existing). **LANDSCAPE only — never portrait/vertical; source via `https://www.pexels.com/search/<term>/?orientation=landscape`; bare URLs, no `?query`, each must end in `.jpg`/`.jpeg`/`.png`/`.webp` — see **Rule: Image URLs**.** Query strings get baked into imported filenames and 404. Pair with `auto_image_import=1` to fetch externals into site storage." }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing portfoliogroup record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** editing metadata (title, description, `group_status`) OR appending photos via `post_image` CSV + `auto_image_import=1` (APPENDS, does not replace). Existing photos are edited via `updateMultiImagePostPhoto` (title/order only).\n\n**Required:** `group_id`.\n\n**Enums:** `group_status`: `0`=Draft, `1`=Published.\n\n**Verify appended photos via `listMultiImagePostPhotos`, NOT via `getMultiImagePost.post_image`.** The parent's `post_image` field is a transient write-through, not a mirror of child rows — it does NOT reflect appended photos. Child rows land in `users_portfolio`. Silent-failure possible (empty `file`, `image_imported=0`) — check each child row.\n\n**`group_name` rename does NOT update `group_filename` (the URL slug).** `group_filename` is writable — see **Rule: URL slug rename** for when to suggest a slug update + redirect. Report `group_filename` from `getMultiImagePost` when giving the user a URL.\n\n**See also:** `createMultiImagePost`, `deleteMultiImagePost`, `deleteMultiImagePostPhoto`.\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - photo rows land asynchronously.\n\n" } }, "/api/v2/users_portfolio_groups/delete": { "delete": { "operationId": "deleteMultiImagePost", "summary": "Delete an album group", "tags": [ "Portfolio Groups" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "group_id" ], "properties": { "group_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a portfoliogroup record by ID. Destructive - cannot be undone via API.\n\n**Use when:** removing the entire album. **Recommended sequence: delete child photos first via `deleteMultiImagePostPhoto` (enumerate via `listMultiImagePostPhotos property=group_id&property_value=<id>&property_operator==`), THEN delete the group.** BD does not cascade — skipping this leaves orphan `users_portfolio` rows.\n\n**Required:** `group_id`.\n\n**See also:** `updateMultiImagePost` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/users_portfolio/get": { "get": { "operationId": "listMultiImagePostPhotos", "summary": "List album photos", "tags": [ "Portfolio Photos" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/property" }, { "$ref": "#/components/parameters/property_value" }, { "$ref": "#/components/parameters/include_marketplace" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of portfoliophoto records. Read-only.\n\n**Use when:** fetching all photos within a multi-image post - always pass `group_id` to filter. For a single photo by ID use `getMultiImagePostPhoto`.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getMultiImagePostPhoto` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/users_portfolio/get/{photo_id}": { "get": { "operationId": "getMultiImagePostPhoto", "summary": "Get a single album photo", "tags": [ "Portfolio Photos" ], "parameters": [ { "name": "photo_id", "in": "path", "required": true, "schema": { "type": "integer" } }, { "$ref": "#/components/parameters/include_marketplace" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single portfoliophoto record. Read-only.\n\n**Use when:** editing or removing one specific photo within an album. You need the `photo_id` (from `listMultiImagePostPhotos`).\n\n**Required:** `photo_id`.\n\n**See also:** `listMultiImagePostPhotos` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/users_portfolio/create": { "post": { "operationId": "createMultiImagePostPhoto", "summary": "Create an album photo", "tags": [ "Portfolio Photos" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "user_id", "group_id" ], "properties": { "user_id": { "type": "integer" }, "group_id": { "type": "integer" }, "title": { "type": "string" }, "original_image_url": { "type": "string", "description": "Image URL for this photo row. **LANDSCAPE only — never portrait/vertical; source via `https://www.pexels.com/search/<term>/?orientation=landscape`; bare URL, no `?query`, must end in `.jpg`/`.jpeg`/`.png`/`.webp` — see **Rule: Image URLs**.** Note: `createMultiImagePostPhoto` does NOT import externals — records the URL as-is. For external URLs that must survive source outages, use `createMultiImagePost`'s bulk `post_image` CSV with `auto_image_import=1` instead." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new portfoliophoto record. Writes live data.\n\n**Use when:** adding ONE image to an existing album where the image is **already hosted on the BD site** (or hotlinking is acceptable). The parent album must exist (call `createMultiImagePost` first to get `group_id`).\n\n**Does NOT import external URLs.** This endpoint has no `auto_image_import` field — the URL is recorded as-is. The parent album's `auto_image_import=1` applies only to photos passed via the parent's bulk `post_image` CSV at create time; it does NOT cascade to subsequent `createMultiImagePostPhoto` calls. For external URLs that must survive source outages (e.g. Pexels, stock sites), do NOT use this endpoint — create a NEW album via `createMultiImagePost` with a bulk CSV `post_image` + `auto_image_import=1`, and delete the old album.\n\n**Required:** `user_id`, `group_id`.\n\n**Parameter interactions:**\n\n- `group_id` - parent album group (from `createMultiImagePost` or `listMultiImagePosts`)\n\n- `original_image_url` - full URL of the image; must already be publicly accessible; stored verbatim (no fetch)\n\n**See also:** `createMultiImagePost` (the correct path for external URLs), `updateMultiImagePostPhoto` (modify title/order only — cannot re-import).\n\n" } }, "/api/v2/users_portfolio/update": { "put": { "operationId": "updateMultiImagePostPhoto", "summary": "Update an album photo", "tags": [ "Portfolio Photos" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "photo_id" ], "properties": { "photo_id": { "type": "integer" }, "title": { "type": "string" }, "order": { "type": "integer" }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing portfoliophoto record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** reordering photos within an album (`order` field) or renaming (`title`).\n\n**Required:** `photo_id`.\n\n**Cannot re-import a failed image.** Only writes `title` and `order`. To fix an `image_imported=0` row: `deleteMultiImagePostPhoto`, then `updateMultiImagePost group_id=<same>&post_image=<new_url>&auto_image_import=1` (appends).\n\n**See also:** `createMultiImagePostPhoto` (add new), `deleteMultiImagePostPhoto` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/users_portfolio/delete": { "delete": { "operationId": "deleteMultiImagePostPhoto", "summary": "Delete an album photo", "tags": [ "Portfolio Photos" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "photo_id" ], "properties": { "photo_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a portfoliophoto record by ID. Destructive - cannot be undone via API.\n\n**Use when:** permanently removing one photo from an album. For \"hide\" use `updateMultiImagePostPhoto` with `status=0`.\n\n**Required:** `photo_id`.\n\n**See also:** `updateMultiImagePostPhoto` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/data_categories/get": { "get": { "operationId": "listPostTypes", "summary": "List post types", "tags": [ "Post Types" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/property" }, { "$ref": "#/components/parameters/property_value" }, { "$ref": "#/components/parameters/include_code" }, { "$ref": "#/components/parameters/include_post_comment_settings" }, { "$ref": "#/components/parameters/include_review_notifications" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of posttype records. Read-only.\n\n**Lean by default:** rows strip the PHP/HTML code-template fields (`search_results_div`, `search_results_layout`, `profile_results_layout`, `profile_header`, `profile_footer`, `category_header`, `category_footer`, `comments_code`), the `post_comment_settings` JSON-string field, and the 5 review-notification email template fields. Strips ~10 admin-form residue fields (`website_id`, `myid`, `method`, `id`, `save`, `form`, `form_fields_name`, `fromcron`, `zzz_fake_field`, `customize`). Per-row drops from ~3.5KB (minimal config) / 15-30KB (populated code) to ~1.5KB. Opt back in with `include_code=1` (when editing post-type templates), `include_post_comment_settings=1`, or `include_review_notifications=1`.\n\n**Use when:** discovering which post types exist on this site AND their `data_type` families. The `data_type` value on each row tells you whether a post type belongs to `createSingleImagePost` (9/20) or `createMultiImagePost` (4). Use this BEFORE calling either create endpoint to pick the correct tool. **Also the standard discovery step for the Member Listings post type** (singleton per site, `data_type=10`, `system_name=member_listings`): filter `property=data_type&property_value=10&property_operator==` to retrieve the single record; the `data_id` varies per site. Member Listings controls the member search results page UI/UX + profile/detail page - see `updatePostType` and **Rule: Post-type code fields** for editable fields, code-group save rules, and master-fallback behavior.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**Payload size - filter, don't enumerate blindly:** each row has ~90 fields, and on sites with customized post types, layout fields (`search_results_layout`, `profile_results_layout`, `search_code_hidden`, `detail_page_code_hidden`) can embed entire PHP templates - responses have been seen at 80k+ chars. BD does not offer a `fields=` response-shaping parameter. The practical fix is to filter and cap:\n\n- If you know the target post type name, filter: `property=data_name&property_value=Blog&property_operator=LIKE&limit=5`\n\n- If you know the `data_id` already, skip this entirely - use `getPostType` (single record)\n\n- For \"discover blog-family post types\": filter by `data_type` (`=4` multi-image, `=20` single-image article/blog/event) with a small `limit` to see only the relevant family\n\n- For full enumeration for site-mapping, set `limit=100` and paginate; expect to burn tokens on a site with many customized types\n\n**See also:** `getPostType` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/data_categories/get/{data_id}": { "get": { "operationId": "getPostType", "summary": "Get a single post type", "tags": [ "Post Types" ], "parameters": [ { "name": "data_id", "in": "path", "required": true, "schema": { "type": "integer" } }, { "$ref": "#/components/parameters/include_code" }, { "$ref": "#/components/parameters/include_post_comment_settings" }, { "$ref": "#/components/parameters/include_review_notifications" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single posttype record. Read-only.\n\n**Lean by default:** same strip rules as `listPostTypes` (see **Rule: Lean read responses**). Pass `include_code=1` to restore the 9 PHP/HTML code-template fields — required when you intend to edit them via `updatePostType` (read current values, modify, write back with all group-mates per **Rule: Post-type code fields**).\n\n**Use when:** checking the configuration of one post type (which `data_type` family, whether active, custom field config, current search-results / profile-page template code). Commonly followed by `getPostTypeCustomFields` to enumerate per-type fields. **Also the canonical read before any `updatePostType` code-field edit** — apply **Rule: Post-type code fields**.\n\n**Required:** `data_id`.\n\n**Code-field master-fallback:** the up to eight HTML/PHP code fields on every post type record (`category_header`, `search_results_div`, `category_footer`, `profile_header`, `profile_results_layout`, `profile_footer`, `search_results_layout`, `comments_code`) begin life backed by the BD-core master template and only persist locally in the site DB when an admin (or API call) saves them. This endpoint returns the MASTER value for any code field that has no local override - so the agent always sees the real rendered code, not an empty string. This matters because any edit to one of the grouped code fields (search-results group = header+loop+footer, profile group = header+body+footer) MUST include all fields in that group on the write (see **Rule: Post-type code fields**). Always read current values here BEFORE calling `updatePostType`.\n\n**See also:** `listPostTypes` (enumerate many; discover Member Listings via `data_type=10` filter), `updatePostType` (write; applies **Rule: Post-type code fields** and **Rule: Member Listings post type**), `getPostTypeCustomFields` (per-type custom field enum).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/data_categories/custom_fields": { "post": { "operationId": "getPostTypeCustomFields", "summary": "Get custom fields for a post type", "tags": [ "Post Types" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "properties": { "data_id": { "type": "integer", "description": "Post type primary key. Pass this OR `system_name` (exactly one)." }, "system_name": { "type": "string", "description": "Post type system name (e.g. `website_blog_article`). Wrapper resolves to `data_id` via `listPostTypes`. Pass this OR `data_id` (exactly one)." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "type": "object" } } } } }, "description": "Fetch a single posttypecustomfields record. Read-only.\n\n**Use when:** building a create/update payload for a post type that has custom fields (most do). Returns the exact per-type schema to send.\n\n**Required:** exactly one of `data_id` (numeric post-type ID) OR `system_name` (string, e.g. `website_blog_article`). When `system_name` is given the wrapper resolves it to `data_id` via `listPostTypes` before calling BD.\n\n**Parameter interactions:**\n\n- `data_id` - the post type to introspect; get via `listPostTypes`\n- `system_name` - friendlier alternative; the wrapper does the lookup\n- Returns custom field definitions specific to this post type - use to build create/update payloads for matching posts\n\n**Discovering enumerated field values (e.g. `post_category`):** per-post-type dropdowns like `post_category` are configured by the site admin and live in this schema. There is NO `createPostCategory` API tool - if the user needs a new dropdown option, that is admin-side work. Call this before a create/update to see the exact allowed values for select/radio/checkbox fields, and pass only those values verbatim.\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/data_categories/update": { "put": { "operationId": "updatePostType", "summary": "Update a post type", "tags": [ "Post Types" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "data_id" ], "properties": { "data_id": { "type": "integer", "description": "Post type primary key. Required. For the Member Listings post type (`data_type=10`, singleton per site), discover via `listPostTypes` filtered by `property=data_type&property_value=10&property_operator==` - `data_id` varies per site." }, "category_tab": { "type": "string", "description": "Admin-UI label for the post-type tab." }, "per_page": { "type": "integer", "description": "Number of results per search results page. Default 9 (Member Listings). Recommended max 500 for site-speed reasons." }, "h1": { "type": "string", "description": "Search Results page H1 heading." }, "h2": { "type": "string", "description": "Search Results page H2 sub-heading." }, "keyword_search_filter": { "type": "string", "enum": [ "level_2", "level_3" ], "description": "Post Keyword Search Options. `level_2` = default fields only (faster). `level_3` = default + custom fields (slower; sites with many posts/custom fields can be significantly slower). Default on Member Listings: `level_3`." }, "enableLazyLoad": { "type": "string", "enum": [ "0", "1", "2" ], "description": "Pagination Display Options. `1` = Insta-Load Search Results (default on Member Listings), `0` = Standard Pagination, `2` = Hide Pagination." }, "category_order_by": { "type": "string", "enum": [ "alphabet-asc", "alphabet-desc", "userid-asc", "userid-desc", "last_name_asc", "last_name_desc", "reviews", "random" ], "description": "Display order of results. `alphabet-asc` (Member Name A-Z, default) / `alphabet-desc` / `userid-asc` (Member ID Oldest First) / `userid-desc` (Newest First) / `last_name_asc` / `last_name_desc` / `reviews` (Most Reviews First) / `random`." }, "category_ignore_search_priority": { "type": "string", "enum": [ "0", "1" ], "description": "When sorting members, respect Membership Plan 'Search Priority'? `0` = Yes (respect plan priority, default), `1` = No (ignore plan priority)." }, "post_type_cache_system": { "type": "string", "enum": [ "0", "1" ], "description": "Enable search-results cache. `1` = Yes (recommended; subsequent matching searches load faster). `0` = No (always query DB).\n\n**Cannot be `1` when `category_order_by=random`** - admin UI disables cache in that case; sending `post_type_cache_system=1` with `category_order_by=random` is an invalid combination." }, "category_sidebar": { "type": "string", "description": "Sidebar to display on search-results pages. Valid values: `\"\"` (no sidebar), a Master Default Sidebar, or a custom sidebar `name` from `listSidebars`. See **Rule: Sidebars** for the canonical Master Default list and selection workflow.\n\n**Semantic equivalent of `form_name` on WebPages** - different variable name, same value set. Default on Member Listings: `Member Search Result`." }, "sidebar_search_module": { "type": "string", "description": "Widget name for the search module inside the sidebar. Must match an existing widget on the site - not server-enforced, BD ships new ones in core releases. Common values: `Bootstrap Theme - Search Module - Keyword_Location` (Member Listings default), `No Search Module - None` (disables), `Bootstrap Theme - Search Module - Keyword Only`, `Bootstrap Theme - Search Module - Local Radius Search`, `Bootstrap Theme - Search Module - Top Category Only`, `Bootstrap Theme - Search Module - Top_Category_Location`, `Bootstrap Theme - Search Module - Top_Sub_Category`, plus Dynamic Category Filter set and post-search variants.\n\nIf unsure which is valid on THIS site, enumerate via `listWidgets` and match by `widget_name`. Also: the `category_sidebar` chosen must contain the `Bootstrap Theme - Search Module - Dynamic Sidebar Search` host widget for the module to render." }, "sidebar_position_mobile": { "type": "string", "enum": [ "top", "bottom", "hide" ], "description": "Sidebar position on mobile devices ONLY (desktop uses `menu_layout`/page-level defaults). `top` = above results, `bottom` = below results (default), `hide` = do not render sidebar on mobile." }, "enable_search_results_map": { "type": "string", "enum": [ "0", "1" ], "description": "Display Google Map option at top of search results pages. `1` = Yes, show the map pin icon (default on Member Listings). `0` = No. Requires the Google Maps site feature to be enabled for the map itself to load." }, "category_header": { "type": "string", "description": "**CODE FIELD - search results HEADER** (HTML/CSS/JS/iframe/PHP; widget-equivalent trust; no input sanitization). Renders ABOVE the member-search results loop. Supports PHP variables (`<?php echo $user_data['full_name']; ?>`) and `%%%text_label%%%` tokens.\n\nPart of the search-results code group - master-fallback on read + all-or-nothing save — see **Rule: Post-type code fields**." }, "search_results_div": { "type": "string", "description": "**CODE FIELD - search results LOOP** (HTML/CSS/JS/iframe/PHP; widget-equivalent trust; no input sanitization). Renders ONCE PER matching member in the results list. Supports PHP variables and `%%%text_label%%%` tokens.\n\nPart of the search-results code group (`category_header` + `search_results_div` + `category_footer`) - master-fallback on read + all-or-nothing save — see **Rule: Post-type code fields**." }, "category_footer": { "type": "string", "description": "**CODE FIELD - search results FOOTER** (HTML/CSS/JS/iframe/PHP; widget-equivalent trust; no input sanitization). Renders BELOW the member-search results loop. Supports PHP variables and `%%%text_label%%%` tokens.\n\nPart of the search-results code group (`category_header` + `search_results_div` + `category_footer`) - master-fallback on read + all-or-nothing save — see **Rule: Post-type code fields**." }, "profile_header": { "type": "string", "description": "**CODE FIELD - profile/detail page HEADER** (HTML/CSS/JS/iframe/PHP; widget-equivalent trust). Renders ABOVE main content on the single-record detail page for post types with per-record detail pages (blog, event, coupon, property, product, etc.).\n\nPart of the profile code group (`profile_header` + `profile_results_layout` + `profile_footer`) - all-or-nothing save rule applies — see **Rule: Post-type code fields**.\n\n**NOT applicable to Member Listings (`data_type=10`)** - members render via BD's core profile system, not a post-type template. Field stores but has no rendering effect; do not send on Member Listings updates." }, "profile_results_layout": { "type": "string", "description": "**CODE FIELD - profile/detail page BODY** (HTML/CSS/JS/iframe/PHP; widget-equivalent trust). Main detail-page template - BD's equivalent of WordPress `single.php`. **Misleading name** - NOT search-results layout; this is the DETAIL page for a single record.\n\nFor Member Listings, this drives the member profile page. **Part of the profile code group** with `profile_header` + `profile_footer` - all-or-nothing save rule applies (send all three together when editing any of them) — see **Rule: Post-type code fields**." }, "profile_footer": { "type": "string", "description": "**CODE FIELD - profile/detail page FOOTER** (HTML/CSS/JS/iframe/PHP; widget-equivalent trust). Renders BELOW the main content on the single-record detail page. **Part of the profile code group** with `profile_header` + `profile_results_layout` — see **Rule: Post-type code fields**." }, "search_results_layout": { "type": "string", "description": "**CODE FIELD - single-record detail page code** (HTML/CSS/JS/iframe/PHP; widget-equivalent trust). **Misleading name** - despite the `search_results_` prefix, this is the DETAIL page code for a single post record (BD's equivalent of WordPress `single.php`).\n\nApplies ONLY to post types with per-record detail pages (blog, event, coupon, property, product, etc.). **NOT applicable to Member Listings (`data_type=10`)** - members render via BD's core member profile system, not a post-type template. Do not send on Member Listings updates.\n\nStandalone - NOT in the profile triplet; saves independently. Uses master-fallback-on-read like other code fields." }, "comments_code": { "type": "string", "description": "**CODE FIELD** - additional detail-page footer/embed code (HTML/CSS/JS/iframe/PHP; widget-equivalent trust). Renders directly AFTER `search_results_layout` on the single-record detail page - for embed widgets, schema markup, analytics pixels, structured data, auxiliary footer code.\n\nApplies ONLY to post types with per-record detail pages (blog, event, coupon, property, product, etc.). **NOT applicable to Member Listings (`data_type=10`)** - no post-type-driven detail page; do not send on Member Listings updates.\n\nStandalone - NOT in the profile triplet or search-results group; saves independently. Uses master-fallback-on-read like other code fields." }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update a post type. PATCH semantics (except per **Rule: Post-type code fields**). Writes live data.\n\n**Cache refresh is automatic.** Response includes `auto_cache_refreshed: true` after successful writes; no manual `refreshSiteCache` call needed. If `auto_cache_refreshed: false`, check `auto_cache_refresh_error` and retry `refreshSiteCache` once.\n\n**Required:** `data_id`.\n\n**Picking the right post type — disambiguation.** Apply **Rule: Resource disambiguation** before editing. \"Edit my classifieds page\" is layer-ambiguous (a WebPage vs. the post type's code group vs. Member Listings UI vs. a category landing) — confirm WHICH layer even when only one record string-matches. Resolve to `data_id` via `listPostTypes` first; never proceed on semantic similarity alone.\n\n**Use when:** toggling a post type active/inactive, renaming, changing per-page display counts, editing search-results UI or profile-page code. For Member Listings specifically: tuning keyword-search, pagination, sidebar, sort order. Custom field DEFINITIONS live in BD admin UI, not API.\n\n**Universal structural safety - NEVER mutate these fields on ANY post type:** `data_type`, `system_name`, `data_name`, `data_active`, `data_filename`, `form_name`, `software_version`, `display_order`. BD system-seeds them; changes break rendering site-wide.\n\n**MEMBER LISTINGS SPECIAL CASE (`data_type=10`).** Every BD site has exactly one post type with `data_type=10` (`system_name=member_listings`) - it controls the Member Search Results page UI/UX. **No profile/detail page of its own** - members render via the normal profile system. `data_id` varies per site; discover via `listPostTypes property=data_type property_value=10 property_operator==`. Cache the `data_id` for the session - it never changes.\n\nMember Listings cheat-sheet (12 commonly-edited UI/UX settings + 3 search-code fields - NOT a limit, any real column is writable per schema-is-documentation): `h1`, `h2`, `per_page`, `keyword_search_filter`, `enableLazyLoad`, `category_order_by`, `category_ignore_search_priority`, `post_type_cache_system`, `category_sidebar`, `sidebar_search_module`, `sidebar_position_mobile`, `enable_search_results_map`, `category_header`, `search_results_div`, `category_footer`.\n\nMember Listings guardrails (apply ONLY to `data_type=10`):\n\n- `profile_header` / `profile_results_layout` / `profile_footer` / `search_results_layout` have NO effect on Member Listings - skip them.\n\n- `data_active` must stay `1`; no legitimate reason to disable via API.\n\n- On other post types (blog, event, coupon, property, product), these ARE legitimate rendering fields - write freely.\n\n**CODE FIELDS - master-fallback on GET + all-or-nothing save per group.** Up to 8 code-template fields begin life backed by BD's MASTER post-type template; they only persist locally when saved. GET returns master value for un-customized fields (agent sees real rendered code, not empty string). Writing ANY field in a group requires sending ALL fields in that group (unchanged fields copied verbatim from prior GET); omitting group-mates causes them to drift back to master on next render.\n\nGroups:\n1. **Search-results (every post type INCLUDING Member Listings):** `category_header` + `search_results_div` + `category_footer`. Send all 3.\n2. **Profile/detail (post types WITH detail pages - NOT Member Listings):** `profile_header` + `profile_results_layout` + `profile_footer`. Send all 3. DO NOT send on Member Listings.\n3. **Standalone (post types WITH detail pages - NOT Member Listings):** `search_results_layout` (single.php analogue - misleading name) and `comments_code` (auxiliary footer, embeds/schema/pixels). Both save independently, no group rule. Master-fallback applies. DO NOT send on Member Listings.\n\n**Code-edit workflow:**\n1. `getPostType(data_id)` - returns current values with master fallback.\n2. Identify target group.\n3. Build payload: changed field(s) + other group-mates copied verbatim from GET.\n4. `updatePostType` with `data_id` + full group. (Cache flush is automatic post-write.)\n\n**Code-field trust level:** all 8 code fields are widget-equivalent - accept arbitrary HTML/CSS/JS/iframes/PHP (BD evaluates PHP server-side at render). XSS/SQLi sanitization rules do NOT apply - anyone editing post-type code already has full site code control. Supports PHP variables (`<?php echo $user_data['full_name']; ?>`) and BD text-label tokens (`%%%text_label%%%`).\n\n**Member Listings code edits affect every member-search page on the site** - confirm intent with user before editing Member Listings code fields.\n\n**See also:** `getPostType`, `listPostTypes` (filter by `data_type`), `deletePostType` (NOT for Member Listings - system-required).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord}, auto_cache_refreshed: true|false, auto_cache_refresh_error?: \"...\" }`." } }, "/api/v2/data_categories/delete": { "delete": { "operationId": "deletePostType", "summary": "Delete a post type", "tags": [ "Post Types" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "data_id" ], "properties": { "data_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a posttype record by ID. Destructive - cannot be undone via API.\n\n**Use when:** removing a post type entirely. Existing posts of this type become orphaned - consider migrating them to another type first via a bulk `updateSingleImagePost`/`updateMultiImagePost`.\n\n**Required:** `data_id`.\n\n**See also:** `updatePostType` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/unsubscribe_list/get": { "get": { "operationId": "listUnsubscribes", "summary": "List unsubscribe records", "tags": [ "Unsubscribe" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of unsubscribe records. Read-only.\n\n**Use when:** auditing the email unsubscribe list - useful for compliance (GDPR, CAN-SPAM) or before launching a new email campaign.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getUnsubscribe` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/unsubscribe_list/get/{id}": { "get": { "operationId": "getUnsubscribe", "summary": "Get a single unsubscribe record", "tags": [ "Unsubscribe" ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single unsubscribe record. Read-only.\n\n**Use when:** checking one unsubscribe record by ID.\n\n**Required:** `id`.\n\n**See also:** `listUnsubscribes` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/unsubscribe_list/create": { "post": { "operationId": "createUnsubscribe", "summary": "Add email to unsubscribe list", "tags": [ "Unsubscribe" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "email" ], "properties": { "email": { "type": "string", "format": "email", "description": "Email address to unsubscribe from ALL site emails. BD unsubscribe is SITE-WIDE scope - no way to unsub from some lists but not others via this endpoint. Adds the email to the global unsubscribe table." }, "definitive": { "type": "integer", "enum": [ 0, 1 ], "description": "1=permanent unsubscribe" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new unsubscribe record. Writes live data.\n\n**Use when:** programmatically opting a member out of emails (e.g., from an external unsubscribe form or CRM sync). BD adds entries itself when members click email unsubscribe links.\n\n**Required:** `email`.\n\n**Enums:** `definitive`: `0`, `1`.\n\n**See also:** `updateUnsubscribe` (modify existing).\n\n**`email` is the only meaningful input.** Pass the email address to opt out. BD adds unsubscribe records to its global unsubscribe list - this applies across all email campaigns for the site. There is no \"unsubscribe from some lists but not others\" granularity via this endpoint; it's all-or-nothing.\n\n" } }, "/api/v2/unsubscribe_list/update": { "put": { "operationId": "updateUnsubscribe", "summary": "Update an unsubscribe record", "tags": [ "Unsubscribe" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "id" ], "properties": { "id": { "type": "integer" }, "definitive": { "type": "integer", "enum": [ 0, 1 ] }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing unsubscribe record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** editing an unsubscribe record. Rare.\n\n**Required:** `id`.\n\n**Enums:** `definitive`: `0`, `1`.\n\n**See also:** `createUnsubscribe` (add new), `deleteUnsubscribe` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/unsubscribe_list/delete": { "delete": { "operationId": "deleteUnsubscribe", "summary": "Remove email from unsubscribe list", "description": "Permanently delete a unsubscribe record by ID. Destructive - cannot be undone via API.\n\n**Use when:** re-subscribing a member (remove their unsubscribe entry). Confirm the member's consent first - don't use to silently re-enable emails.\n\n**Required:** `id`.\n\n**See also:** `updateUnsubscribe` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n", "tags": [ "Unsubscribe" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "id" ], "properties": { "id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } } } }, "/api/v2/data_widgets/get": { "get": { "operationId": "listWidgets", "summary": "List widgets", "tags": [ "Widgets" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of widget records. Read-only.\n\n**Use when:** discovering the reusable HTML/CSS/JS components available for embedding in pages (via `[widget=Name]` shortcode) or email templates. For fetching one specific widget by ID use `getWidget`.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability. Useful filter: `widget_viewport=front` to list only public-facing widgets.\n\n**See also:** `getWidget` (single by ID), `createWidget` (add new), `updateWidget` (modify).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record carries the full widget object (fields enumerated in the table that follows).\n\n\n**Widget object fields (from BD support article 12000108056):**\n\n| Field | Type | Description |\n|---|---|---|\n| `widget_id` | integer | Primary key (read-only) |\n| `widget_name` | string | Widget name/label - REQUIRED on create; unique per site |\n| `widget_type` | string | Widget classification (default: `Widget`) |\n| `widget_data` | text | Widget HTML content |\n| `widget_style` | text | Widget CSS styles |\n| `widget_javascript` | text | Widget JavaScript code |\n| `widget_settings` | text | Configuration (JSON or serialized) |\n| `widget_values` | text | Widget variable values |\n| `widget_class` | string | CSS class names applied to container |\n| `widget_viewport` | string | Where widget appears: `front`, `admin`, `both` |\n| `widget_html_element` | string | Container element (default: `div`) |\n| `div_id` | string | HTML ID attribute for container |\n| `short_code` | string | Shortcode reference for this widget |\n| `bootstrap_enabled` | integer | `1` if Bootstrap framework loaded |\n| `ssl_enabled` | integer | `1` if SSL/HTTPS required |\n| `mobile_enabled` | integer | `1` if mobile viewport enabled |\n| `file_type` | string | File type of the widget |\n| `revision_timestamp` | timestamp | Last modified (auto-updated) |\n\n" } }, "/api/v2/data_widgets/get/{widget_id}": { "get": { "operationId": "getWidget", "summary": "Get a single widget", "tags": [ "Widgets" ], "parameters": [ { "name": "widget_id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single widget record by `widget_id`. Returns the raw HTML/CSS/JS source. Read-only.\n\n**Use when:** you have a `widget_id` (from `listWidgets` or admin) and want the widget's SOURCE code to edit or audit. To preview the rendered widget on the front-end, embed it on a page via `[widget=Name]` shortcode and view the page.\n\n**Required:** `widget_id` (path parameter).\n\n**See also:** `listWidgets` (enumerate), `updateWidget` (modify).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record with all widget fields (`widget_data` = HTML, `widget_style` = CSS, `widget_javascript` = JS, plus metadata).\n\n\nFor the full field list, see `listWidgets`.\n\n" } }, "/api/v2/data_widgets/create": { "post": { "operationId": "createWidget", "summary": "Create a widget", "tags": [ "Widgets" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "widget_name" ], "properties": { "widget_name": { "type": "string" }, "widget_data": { "type": "string", "description": "HTML only. No `<style>`, no `<script>` — render strips backslashes here (`\\d`→`d`, `\\n`→`n`, `\\t`→`t`, `\\\\`→`\\`). JS with stripped escapes throws SyntaxError on parse — every handler unbound, widget renders but no clicks/inputs work. Fix: relocate to `widget_javascript`, do not rewrite JS to avoid backslashes. Put CSS in `widget_style`, JS in `widget_javascript`. See **Rule: Widget code fields**." }, "widget_style": { "type": "string", "description": "Raw CSS. No `<style>` wrapper — BD wraps at render. Wholly-wrapped value: outer wrapper stripped on storage; concatenated wrappers not stripped. See **Rule: Widget code fields**." }, "widget_javascript": { "type": "string", "description": "JS with `<script>...</script>` wrapper required. BD does not auto-wrap; unwrapped content renders as inert text. No backslash-strip on this field — regex literals (`\\d`, `\\w`, `\\s`) AND string escapes (`\\n`, `\\t`, `\\\\`) survive intact. See **Rule: Widget code fields**." }, "widget_viewport": { "type": "string", "enum": [ "front", "admin", "both" ] }, "bootstrap_enabled": { "type": "integer", "enum": [ 0, 1 ] } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new widget (reusable HTML/CSS/JS component). Writes live data.\n\n**Cache refresh is automatic.** Response includes `auto_cache_refreshed: true` after successful writes; no manual `refreshSiteCache` call needed. If `auto_cache_refreshed: false`, check `auto_cache_refresh_error` and retry `refreshSiteCache` once.\n\n**Use when:** programmatically adding a new reusable block to embed via `[widget=Name]` shortcode on pages or email templates. Rare in practice - widgets are usually created via BD admin UI where the editor supports live preview. API creation is useful for bulk imports, cross-site migrations, or scripted widget generation.\n\n**Required:** `widget_name` (should be unique per site).\n\n**widget_name format:** alphanumeric + spaces + hyphens + plus + underscores only (`[A-Za-z0-9 -+_]+`). Special chars (slashes, dots, ampersands, quotes, brackets, etc.) break `[widget=Name]` shortcode resolution and are runtime-rejected by the wrapper. Examples: `Mortgage Calculator`, `Service-Card`, `Email_Validator_v2`, `C++ Course`.\n\n**Pre-check before create:** BD does NOT enforce uniqueness on `widget_name`. Duplicates break `[widget=Name]` shortcode resolution - which widget renders at the shortcode is undefined. Do a **server-side filter-find**: `listWidgets property=widget_name property_value=<proposed> property_operator==`. Zero rows = name free; >=1 row = taken. **Do NOT paginate unfiltered lists looking for the name** - on sites with hundreds of custom widgets that burns rate limit for nothing.\n\n**On collision (auto-suffix flow):** if the proposed name is taken, append `-v2` and re-check. Still taken? Try `-v3`, `-v4`, ... up through `-v10`. First free suffix wins. Only if all 10 are taken, ask the user for a different base name. Never silently create a duplicate.\n\n**Route by type BEFORE writing values:** decide what each piece of code is, then put it in the matching field — HTML → `widget_data`, CSS → `widget_style`, JS → `widget_javascript`. A self-contained block with all three concatenated into `widget_data` will save successfully but silently break: `widget_data` strips backslashes on render, mangling regex literals (`\\d`, `\\s`), string escapes (`\\n`, `\\t`), and unicode escapes (`\\u0022`). The other two fields do not strip backslashes. Split by type from the start.\n\n**Common fields on create:**\n\n- `widget_data` - the HTML content\n\n- `widget_style` - CSS (scoped to the widget via `widget_class` or `div_id`)\n\n- `widget_javascript` - JS (runs when widget is rendered on a page)\n\n- `widget_viewport` - `front` (public), `admin` (admin panel only), or `both`\n\n- `bootstrap_enabled=1` - ensures Bootstrap framework loaded when this widget is rendered\n\n- `widget_html_element` - wrapper element (default `div`)\n\n**See also:** `updateWidget` (modify existing), `listWidgets` (check if name is taken first), `getWidget` (verify storage after create).\n\n**Writes live data:** the widget is available immediately but does nothing until referenced by a `[widget=Name]` shortcode on a page or email template.\n\n**Returns:** `{ status: \"success\", message: {...createdRecord}, auto_cache_refreshed: true|false, auto_cache_refresh_error?: \"...\" }` including the new `widget_id`.\n\n**Post-create verification (recommended, especially when uncertain about routing):** call `getWidget` once to confirm `widget_data` contains only HTML, `widget_style` contains your CSS, and `widget_javascript` contains your JS wrapped in `<script>...</script>`. If anything landed in the wrong field, call `updateWidget` to relocate **before the user tests the widget**. Proactive relocation here is correct and does NOT violate the \"don't relocate without user-reported breakage\" rule on `updateWidget` — that rule applies to subsequent edits, not to self-correcting your own just-created record.\n\n\nFor the full field list, see `listWidgets`.\n\n" } }, "/api/v2/data_widgets/update": { "put": { "operationId": "updateWidget", "summary": "Update a widget", "tags": [ "Widgets" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "widget_id" ], "properties": { "widget_id": { "type": "integer" }, "widget_name": { "type": "string" }, "widget_data": { "type": "string", "description": "HTML. Render strips backslashes here (`\\d`→`d`, `\\n`→`n`, `\\t`→`t`, `\\\\`→`\\`). JS with stripped escapes throws SyntaxError on parse — every handler unbound, widget renders but no clicks/inputs work. Fix: relocate to `widget_javascript`, do not rewrite JS to avoid backslashes. Never relocate existing `<style>`/`<script>` blocks here — only on user-reported breakage. New content: route by type (CSS→`widget_style`, JS→`widget_javascript`). See **Rule: Widget code fields**." }, "widget_style": { "type": "string", "description": "Raw CSS. No `<style>` wrapper — BD wraps at render. Wholly-wrapped value: outer wrapper stripped on storage; concatenated wrappers not stripped. See **Rule: Widget code fields**." }, "widget_javascript": { "type": "string", "description": "JS with `<script>...</script>` wrapper required. BD does not auto-wrap; unwrapped content renders as inert text. No backslash-strip on this field — regex literals (`\\d`, `\\w`, `\\s`) AND string escapes (`\\n`, `\\t`, `\\\\`) survive intact. See **Rule: Widget code fields**." }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing widget by `widget_id`. Fields omitted are untouched. Writes live data.\n\n**Cache refresh is automatic.** Response includes `auto_cache_refreshed: true` after successful writes; no manual `refreshSiteCache` call needed. If `auto_cache_refreshed: false`, check `auto_cache_refresh_error` and retry `refreshSiteCache` once.\n\n**Use when:** editing widget HTML (`widget_data`), CSS (`widget_style`), JS (`widget_javascript`), or metadata. Any page or email referencing this widget via `[widget=Name]` shortcode will render the updated content on next view.\n\n**Required:** `widget_id`.\n\n**Common edits:**\n\n- Content: `widget_data`, `widget_style`, `widget_javascript`\n\n- Visibility: `widget_viewport` (`front`/`admin`/`both`)\n\n- Framework: `bootstrap_enabled`, `mobile_enabled`, `ssl_enabled`\n\n**Renaming via `widget_name`:** **DO NOT pass `widget_name` unless the user explicitly asks to rename the widget.** Renaming a widget breaks every `[widget=Name]` shortcode reference to its old name on every page/email — silently. If the user does ask: same format rules as create (`[A-Za-z0-9 -+_]+`, runtime-rejected on bad chars); on collision follow the auto-suffix flow (`-v2`, `-v3`, ... up to `-v10`).\n\n**See also:** `createWidget` (add new), `deleteWidget` (remove).\n\n**Writes live data:** edits go live immediately for new page loads.\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord}, auto_cache_refreshed: true|false, auto_cache_refresh_error?: \"...\" }`.\n\n\nFor the full field list, see `listWidgets`.\n\n" } }, "/api/v2/data_widgets/delete": { "delete": { "operationId": "deleteWidget", "summary": "Delete a widget", "tags": [ "Widgets" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "widget_id" ], "properties": { "widget_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a widget by `widget_id`. Destructive - cannot be undone via API.\n\n**Use when:** removing an unused widget. For \"disable without deleting\" use `updateWidget` with `widget_viewport=admin` (hides from public pages) - preserves the source for later use.\n\n**Destructive caveat:** any page or email using `[widget=Name]` shortcode referencing the deleted widget will render as empty or broken at that spot. Audit with `listWidgets` + check page content for shortcodes referencing this widget's `widget_name` or `short_code` before deleting.\n\n**Required:** `widget_id`.\n\n**See also:** `updateWidget` with `widget_viewport=admin` (reversible hide).\n\n**Returns:** `{ status: \"success\", message: \"data_widgets record was deleted\" }`.\n\n" } }, "/api/v2/data_widgets/render": { "post": { "operationId": "renderWidget", "summary": "Render a widget to HTML", "description": "**Diagnostic tool only.** Returns BD's rendered HTML output for a widget — useful for confirming render-pipeline symptoms during troubleshoot (backslash strip on `widget_data`, `<style>` auto-wrap on `widget_style`, `<script>` wrapper presence on `widget_javascript`). **Production widget rendering on a customer's site is always via `[widget=Name]` shortcode in page or email content — never call this tool to deliver widget HTML to end users.**\n\n**Use when:** the user reports a widget is broken and you need to see what BD's render pipeline actually emits. See **Rule: Widget code fields** scenario 3 (TROUBLESHOOT).\n\n**Required:** either `widget_id` OR `widget_name`.\n\n**Returns (distinct from standard envelope):** `{ status, message, name, output }`. The `output` field contains rendered `widget_data` HTML with template tokens expanded, plus BD's auto-wrapped `<style type='text/css'>`-block from `widget_style`, plus the verbatim `widget_javascript` content. CSS and JS are NOT in `output` if their fields are empty.\n\n**See also:** `getWidget` (raw source for inspecting field placement), `updateWidget` (apply fixes after diagnosis).", "tags": [ "Widgets" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "properties": { "widget_id": { "type": "integer" }, "widget_name": { "type": "string", "description": "Alternative to `widget_id` - pass either one. Widget name lookup is case-sensitive and must match exactly." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string" }, "message": { "type": "string" }, "name": { "type": "string" }, "output": { "type": "string", "description": "Rendered HTML" } } } } } } } } }, "/api/v2/email_templates/get": { "get": { "operationId": "listEmailTemplates", "summary": "List email templates", "tags": [ "Email Templates" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "name": "include_body", "in": "query", "description": "Opt in to return the full `email_body` HTML. Default stripped — `email_body` is the heaviest field on the row (~8 KB avg, up to tens of KB) and is rarely needed when enumerating templates.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of emailtemplate records. Read-only.\n\n**Use when:** enumerating the site's transactional/marketing email templates before editing. Common audit: before bulk updating \"from\" addresses or footers.\n\n**Lean-by-default:** `email_body` (the HTML body, the heaviest field per row) is stripped. All identity/metadata fields (`email_id`, `email_name`, `email_subject`, `email_type`, `category_id`, `notemplate`, etc.) are always kept. Set `include_body=1` to restore.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getEmailTemplate` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object minus `email_body` unless `include_body=1`.\n\n" } }, "/api/v2/email_templates/get/{email_id}": { "get": { "operationId": "getEmailTemplate", "summary": "Get a single email template", "tags": [ "Email Templates" ], "parameters": [ { "name": "email_id", "in": "path", "required": true, "schema": { "type": "integer" } }, { "name": "include_body", "in": "query", "description": "Opt in to return the full `email_body` HTML. Default stripped — `email_body` is the heaviest field on the row. Set `include_body=1` when you actually need the HTML to edit it.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single emailtemplate record. Read-only.\n\n**Use when:** fetching one template's HTML body and subject for edit.\n\n**Required:** `email_id`.\n\n**Lean-by-default:** `email_body` is stripped. Set `include_body=1` to restore it (always do this when you need to edit the HTML).\n\n**See also:** `listEmailTemplates` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Record omits `email_body` unless `include_body=1`. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/email_templates/create": { "post": { "operationId": "createEmailTemplate", "summary": "Create an email template", "tags": [ "Email Templates" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "email_name" ], "properties": { "email_name": { "type": "string", "description": "Internal name for this email template (used by `[email-template name=...]` references and admin lookups). **Lowercase, hyphens, no spaces** (e.g. `welcome-email`, `password-reset`, `lead-notification-admin`) — see **Rule: Email template recipe**." }, "email_subject": { "type": "string", "description": "Supports merge tags" }, "email_body": { "type": "string", "description": "Content-only HTML — BD wraps it in the document scaffold. Open with any content tag (`<p>`, `<table>`, `<div>`, `<h1>`/`<h2>`, `<img>`, etc.). Inline `style=\"\"` only — no `<style>` blocks (Outlook strips them) and no `class=\"\"` (emails have no site stylesheet). Gradients need a fallback `background-color:` first. Verify image URLs return 200 before embedding when possible. Supports `%%%merge_tag%%%` tokens and `[widget=Name]` shortcodes. See **Rule: Email template recipe**." }, "email_type": { "type": "string" }, "triggers": { "type": "string", "description": "Comma-separated events" }, "website": { "type": "integer", "description": "0=platform-wide" }, "email_from": { "type": "string" }, "priority": { "type": "integer" }, "signature": { "type": "integer", "enum": [ 0, 1 ], "description": "Append the site's default email signature to this template. Default `0`. Set to `1` only when the user explicitly asks to include the site signature; BD appends it automatically at send time." }, "category_id": { "type": "integer", "enum": [ 0, 1, 3, 4, 15, 16 ], "default": 0, "description": "Template category. On `create`: default to `0` (My Saved Templates); other values (`1`/`3`/`4`/`15`/`16`) are system-populated — do NOT create under them. `update` is unrestricted in any category." }, "notemplate": { "type": "integer", "enum": [ 0, 1, 2, 3, 4 ], "default": 2, "description": "Template + logo wrapper mode. `0` = template + logo left; `2` = template + logo center (default — use unless user specifies otherwise or it's a plaintext email); `3` = template + logo right; `4` = template, no logo; `1` = no template or logo (plaintext-only). When this is anything other than `1`, BD's global template already wraps `email_body` in a 600px-wide constraining table — do NOT add your own outer max-width wrapper in that case." }, "content_type": { "type": "string" }, "unsubscribe_link": { "type": "integer", "enum": [ 0, 1 ] } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new emailtemplate record. Writes live data.\n\n**Use when:** adding a new transactional/marketing template. Rare - most BD email templates are built into the admin UI.\n\n**Required:** `email_name`.\n\n**Pre-check before create:** BD does NOT enforce uniqueness on `email_name`. Duplicates cause the wrong template to fire on transactional triggers. Do a **server-side filter-find**: `listEmailTemplates property=email_name property_value=<proposed> property_operator==`. Zero rows = name free; >=1 row = taken. **Do NOT paginate unfiltered lists looking for the name** - on sites with many templates that burns rate limit for nothing. If taken: reuse via `updateEmailTemplate`, OR ask the user, OR pick an alternate `email_name` and re-check. Never silently create a duplicate.\n\n**Enums:** `signature`: `0`/`1`; `notemplate`: default `2` (template + logo center); other values `0` (logo left), `3` (logo right), `4` (template, no logo), `1` (plaintext-only, no wrapper); `category_id`: default `0` (My Saved Templates) — `1`/`3`/`4`/`15`/`16` are system-populated, do NOT create under them; `unsubscribe_link`: `0`/`1`.\n\n**Parameter interactions:**\n\n- `email_subject` and `email_body` can use template tokens (e.g. `%%%website_name%%%`, recipient field tokens)\n\n- `email_body` supports HTML\n\n**See also:** `updateEmailTemplate` (modify existing).\n\n**On create: `email_name` is the only required field.** Subject and body are optional at create time - you can create a template stub and fill in `email_subject` / `email_body` via `updateEmailTemplate` later. This lets you programmatically scaffold templates before customizing them via the admin UI.\n\n" } }, "/api/v2/email_templates/update": { "put": { "operationId": "updateEmailTemplate", "summary": "Update an email template", "tags": [ "Email Templates" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "email_id" ], "properties": { "email_id": { "type": "integer" }, "email_name": { "type": "string", "description": "Internal name for this email template (used by `[email-template name=...]` references and admin lookups). **Lowercase, hyphens, no spaces** (e.g. `welcome-email`, `password-reset`, `lead-notification-admin`) — see **Rule: Email template recipe**." }, "email_subject": { "type": "string", "description": "Supports merge tags" }, "email_body": { "type": "string", "description": "Content-only HTML — BD wraps it in the document scaffold. Open with any content tag (`<p>`, `<table>`, `<div>`, `<h1>`/`<h2>`, `<img>`, etc.). Inline `style=\"\"` only — no `<style>` blocks (Outlook strips them) and no `class=\"\"` (emails have no site stylesheet). Gradients need a fallback `background-color:` first. Verify image URLs return 200 before embedding when possible. Supports `%%%merge_tag%%%` tokens and `[widget=Name]` shortcodes. See **Rule: Email template recipe**." }, "email_type": { "type": "string" }, "triggers": { "type": "string", "description": "Comma-separated events" }, "website": { "type": "integer", "description": "0=platform-wide" }, "email_from": { "type": "string" }, "priority": { "type": "integer" }, "signature": { "type": "integer", "enum": [ 0, 1 ], "description": "Append the site's default email signature to this template. Set to `1` to include the site signature; BD appends it automatically at send time." }, "category_id": { "type": "integer", "enum": [ 0, 1, 3, 4, 15, 16 ], "description": "Template category. `update` is unrestricted across `0`/`1`/`3`/`4`/`15`/`16`. (On `create`, only `0` is allowed — other values are system-populated.)" }, "notemplate": { "type": "integer", "enum": [ 0, 1, 2, 3, 4 ], "description": "Template + logo wrapper mode. `0` = template + logo left; `2` = template + logo center; `3` = template + logo right; `4` = template, no logo; `1` = no template or logo (plaintext-only). When this is anything other than `1`, BD's global template wraps `email_body` in a 600px-wide containing table — do NOT add your own outer max-width wrapper in that case." }, "content_type": { "type": "string" }, "unsubscribe_link": { "type": "integer", "enum": [ 0, 1 ] }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing emailtemplate record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** editing any field on an existing template — subject, body, wrapper mode (`notemplate`), category, signature, triggers, etc. Mirrors `createEmailTemplate` field-for-field; only `email_id` is required.\n\n**Required:** `email_id`.\n\n**Enums (same as `createEmailTemplate`):** `signature`: `0`/`1`; `notemplate`: `0` (logo left), `2` (logo center), `3` (logo right), `4` (template, no logo), `1` (plaintext-only, no wrapper); `category_id`: `0`/`1`/`3`/`4`/`15`/`16` (unrestricted on update); `unsubscribe_link`: `0`/`1`.\n\n**See also:** `createEmailTemplate` (add new), `deleteEmailTemplate` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` — the full updated record after changes applied.\n\n" } }, "/api/v2/email_templates/delete": { "delete": { "operationId": "deleteEmailTemplate", "summary": "Delete an email template", "tags": [ "Email Templates" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "email_id" ], "properties": { "email_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a emailtemplate record by ID. Destructive - cannot be undone via API.\n\n**Use when:** removing a deprecated template. BD may fall back to defaults if a required system template is deleted - confirm before purging.\n\n**Required:** `email_id`.\n\n**See also:** `updateEmailTemplate` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/form/get": { "get": { "operationId": "listForms", "summary": "List forms", "tags": [ "Forms" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of form records. Read-only.\n\n**Use when:** enumerating the site's forms (signup, contact, quote request, custom forms). Child fields are fetched separately via `listFormFields`.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getForm` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/form/get/{form_id}": { "get": { "operationId": "getForm", "summary": "Get a single form", "tags": [ "Forms" ], "parameters": [ { "name": "form_id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single form record. Read-only.\n\n**Use when:** fetching one form's metadata.\n\n**Required:** `form_id`.\n\n**See also:** `listForms` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/form/create": { "post": { "operationId": "createForm", "summary": "Create a form", "tags": [ "Forms" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "form_name", "form_title", "form_action", "form_layout", "form_table", "form_url", "form_class", "table_index", "form_action_div", "form_email_on", "form_success_message" ], "properties": { "form_name": { "type": "string", "description": "System slug (lowercase alphanumerics + hyphens + underscores; NO spaces). Hyphens and underscores are NOT interchangeable — the stored value is the lookup key, byte-exact, in `[form=<form_name>]` shortcodes and on `createFormField` parent references. Immutable post-create. Must be unique per site. For the human-readable nickname use `form_title`. Example: `form_name=strength_blueprint_ebook`." }, "form_title": { "type": "string", "description": "Human-friendly nickname (free text — spaces and any characters allowed). Surfaced in admin UI and form-listing screens. Example: `form_title=\"Strength Blueprint Ebook\"`. NOT the same as `form_name` (the system slug)." }, "form_action": { "type": "string", "default": "post", "enum": [ "post", "get" ], "description": "**post** = form submits via POST body (default, most forms). **get** = form submits as URL query string (use for bookmarkable search/filter forms)." }, "form_layout": { "type": "string", "default": "bootstrapvertical", "enum": [ "bootstrapvertical", "bootstrap" ], "description": "**bootstrapvertical** = Labels Above Inputs (canonical default). **bootstrap** = Labels Left of Inputs (canonical default for Member-dashboard class — `form_action_type=default`). User override wins in either case." }, "form_table": { "type": "string", "enum": [ "website_contacts", "leads", "users_data" ], "description": "Database table the form submissions post into. `website_contacts` = Standard public class (default for ALL public capture; free-create with this tool). `leads` = Lead-saving class (BD's Get-Matched member-routing ONLY) — never free-create from scratch; clone `bootstrap_get_match` per **Rule: Forms** § Lead-match special case. `users_data` = Member-dashboard class — never free-create from scratch; clone-and-assign one of the 3 canonical dashboard forms per § Member-dashboard special case. See § Form classes for the picking rule. Once set on create, treat as immutable.", "default": "website_contacts" }, "form_class": { "type": "string", "default": "form-control", "description": "CSS class applied to every field — UI consistency insurance. Canonical: `form-control`. Per-field `input_class` layers extra CSS on top." }, "form_email_on": { "type": "integer", "enum": [ 0, 1 ], "default": 0, "description": "Send admin notification email on each submission. `0` = OFF (default when an agent creates a form - safer: spammy forms won't flood the admin inbox). `1` = ON. Admin UI defaults this to ON; agents default it to OFF unless user explicitly requests notifications." }, "form_email_recipient": { "type": "string", "description": "Comma-separated email list to receive submission notifications. Used only when `form_email_on=1`. Empty = site default admin recipient." }, "form_action_type": { "type": "string", "enum": [ "", "widget", "notification", "redirect", "default" ], "default": "widget", "description": "Post-submit behavior:\n\n- `widget` = Success Pop-Up (agent default).\n- `notification` = Inline Success Alert Banner.\n- `redirect` = Redirect to URL (wrapper-enforced: `form_target` required, see `form_target` field).\n- `default` = Member-dashboard class (admin-clone-only; do not create from scratch).\n- `\"\"` (empty) = no post-submit behavior (valid only for internal/programmatic forms, NOT public-facing).\n\nFor public-facing values (`widget` / `notification` / `redirect`), Button-last is the agent-side tail-pattern responsibility (NOT wrapper-enforced). See **Rule: Forms** § Form-level recipe." }, "form_target": { "type": "string", "description": "Destination URL. **Required when `form_action_type=redirect`** — wrapper refuses the call without it. Full URL with `https://`, e.g. `https://mysite.com/thanks`." }, "form_url": { "type": "string", "default": "/api/widget/json/post/Bootstrap%20Theme%20-%20Function%20-%20Save%20Form", "description": "Save Action URL - overrides default `action=` on the rendered form. **Required exact value for every form an agent creates:** `/api/widget/json/post/Bootstrap%20Theme%20-%20Function%20-%20Save%20Form`.\n\nThis is the BD Save Form widget endpoint; without it, submit wiring breaks. Do NOT decode the `%20` - BD needs the URL-encoded form verbatim." }, "table_index": { "type": "string", "default": "ID", "description": "Primary key column matching `form_table`: `website_contacts` → `ID`, `leads` → `lead_id`, `users_data` → `user_id`. Required exact match — BD uses this to look up individual submissions." }, "form_action_div": { "type": "string", "default": "#main-content", "description": "Target element ID (CSS selector including `#`) swapped in the DOM on form submit by the `widget` (Success Pop-Up) action type; harmlessly ignored on `notification` / `redirect`. **Always send `#main-content`** unless the user explicitly names a different target — it's the canonical default for every `form_action_type`. Must include leading `#`." }, "form_success_message": { "type": "string", "default": "Your Message has been Received", "description": "Post-submit success copy. Canonical default for Standard public AND Lead-saving classes: `Your Message has been Received`. Override only on explicit user request. Applies to `form_action_type` ∈ {`widget`, `notification`, `redirect`}; not used by `default` (member-dashboard) class." }, "label_to_placeholder": { "type": "string", "enum": [ "0", "1" ], "default": "0", "description": "Form-level toggle. When `\"1\"`, BD collapses each field's `field_text` (label) into placeholder text inside the input — saves vertical space, removes the explicit `<label>` element above each field. Per-field `field_placeholder` is overridden when this is on. Default `\"0\"`." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new form record. Writes live data. Add fields afterward via `createFormField`.\n\n**Required:** `form_name`, `form_title`, `form_action`, `form_layout`, `form_table`, `form_url`, `form_class`, `table_index`, `form_action_div`, `form_email_on`, `form_success_message`.\n\nClass selection — see **Rule: Forms** § Form classes before picking `form_table`. Follow § Form-level recipe for the canonical creation recipe.\n\nMandatory `form_name` pre-check per **Rule: Pre-check natural keys** — BD does NOT enforce uniqueness; duplicate `form_name` produces ambiguous `[form=…]` shortcode resolution.\n\nWrapper-enforced refusal: `form_action_type=redirect` AND empty `form_target` → call refused (see **Rule: Forms** § Wrapper-enforced invariants).\n\n**See also:** `updateForm`, `createFormField`, `listFormFields`.\n\n**Returns:** `{ status: \"success\", message: {...createdRecord} }`.\n\n" } }, "/api/v2/form/update": { "put": { "operationId": "updateForm", "summary": "Update a form", "tags": [ "Forms" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "form_id" ], "properties": { "form_id": { "type": "integer" }, "form_title": { "type": "string" }, "form_email_on": { "type": "integer", "enum": [ 0, 1 ], "description": "Send admin notification email on each submission. `0` = OFF, `1` = ON." }, "form_action_type": { "type": "string", "enum": [ "", "widget", "notification", "redirect", "default" ], "description": "Post-submit behavior. `widget` = success pop-up, `notification` = success alert banner, `redirect` = send user to `form_target` URL (wrapper-enforced: `form_target` required, see `form_target` field), `default` = member-dashboard class (admin-clone-only), `\"\"` = no behavior (internal-only forms). When flipping FROM empty TO a public-facing value, verify the tail pattern (Button-last is agent-side, NOT wrapper-enforced) via `listFormFields`. See **Rule: Forms** § Form-level recipe." }, "form_target": { "type": "string", "description": "Destination URL, required when `form_action_type=redirect`, ignored otherwise. Full URL with `https://`." }, "form_url": { "type": "string", "description": "Save Action URL. Canonical value: `/api/widget/json/post/Bootstrap%20Theme%20-%20Function%20-%20Save%20Form`. If a form was created via this API with the correct value, leave this field alone on update - only touch it to repair a form that was created without it." }, "table_index": { "type": "string", "description": "Primary key column matching `form_table`: `website_contacts` → `ID`, `leads` → `lead_id`, `users_data` → `user_id`. Leave alone on update unless repairing a broken form." }, "form_action_div": { "type": "string", "description": "Target element ID (CSS selector with `#`) swapped on submit by the `widget` action type; harmlessly ignored on `notification` / `redirect`. Canonical value: `#main-content`. Override only when the user explicitly names a different target." }, "form_success_message": { "type": "string", "description": "Post-submit success copy. Canonical default for Standard public AND Lead-saving classes: `Your Message has been Received`. If the existing record already has a value and the user hasn't flagged the message as a problem, leave it alone. Only set this on update when (a) the user asks for different copy, or (b) the field is empty and you're filling in the canonical default. Applies to `form_action_type` ∈ {`widget`, `notification`, `redirect`}; not used by `default` class." }, "label_to_placeholder": { "type": "string", "enum": [ "0", "1" ], "description": "Form-level toggle. When `\"1\"`, BD collapses each field's `field_text` (label) into placeholder text inside the input. Per-field `field_placeholder` is overridden when this is on." }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing form record by ID. Fields omitted are untouched. Writes live data.\n\n**Required:** `form_id`.\n\nCross-refs same as `createForm` — see **Rule: Forms** § Form-level recipe / § Lead-match / § Member-dashboard. Before flipping `form_action_type` to a public-facing value, run `listFormFields` to confirm the tail pattern exists.\n\nWrapper-enforced refusal: `form_action_type=redirect` AND empty `form_target` → call refused.\n\n**See also:** `createForm`, `deleteForm`, `listFormFields` / `createFormField`.\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }`.\n\n" } }, "/api/v2/form/delete": { "delete": { "operationId": "deleteForm", "summary": "Delete a form", "tags": [ "Forms" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "form_id" ], "properties": { "form_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a form record by ID. Destructive - cannot be undone via API.\n\n**Use when:** removing a form - child fields orphan.\n\n**Required:** `form_id`.\n\n**See also:** `updateForm` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/form_fields/get": { "get": { "operationId": "listFormFields", "summary": "List form fields", "tags": [ "Form Fields" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/include_view_flags" }, { "$ref": "#/components/parameters/include_meta" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of formfield records. Read-only.\n\n**Use when:** listing fields on a form. Filter by `form_name` (text slug — `form_fields` joins to `forms` by `form_name`, not `form_id`).\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getFormField` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/form_fields/get/{field_id}": { "get": { "operationId": "getFormField", "summary": "Get a single form field", "tags": [ "Form Fields" ], "parameters": [ { "name": "field_id", "in": "path", "required": true, "schema": { "type": "integer" } }, { "$ref": "#/components/parameters/include_view_flags" }, { "$ref": "#/components/parameters/include_meta" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single formfield record. Read-only.\n\n**Use when:** one field by ID.\n\n**Required:** `field_id`.\n\n**See also:** `listFormFields` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/form_fields/create": { "post": { "operationId": "createFormField", "summary": "Create a form field", "tags": [ "Form Fields" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "form_name", "field_name", "field_text", "field_type", "field_order" ], "properties": { "form_name": { "type": "string", "description": "Parent form slug" }, "field_name": { "type": "string", "description": "Internal system key for this field. **Underscores only, no spaces** - e.g. `first_name`, `company_email`. Used as the HTML `name` attribute." }, "field_text": { "type": "string", "description": "Public-facing display label shown to the user. Free-form text - e.g. \"First Name\", \"Your Email Address\"." }, "field_type": { "type": "string", "enum": [ "Checkbox", "Select", "Radio", "YesNo", "Custom", "Email", "HTML", "Button", "Textbox", "textarea", "Url", "Date", "DateTimeLocal", "File", "FroalaEditor", "FroalaEditorUserUpload", "FroalaEditorUserUploadPreMadeElem", "FroalaEditorAdmin", "Tip", "Hidden", "Country", "State", "Number", "Password", "Phone", "CountryCodePhone", "Pricebox", "ReCaptcha", "HoneyPot", "Category", "Years" ], "description": "Form field type. Copy spelling exactly — most are TitleCase but `textarea` is lowercase. Grouping + use cases at **Rule: Forms** § Field anatomy → Valid field_type values." }, "field_order": { "type": "integer", "description": "Display position (lower = earlier). New forms: multiples of 10 (10, 20, 30…), security tail included. Adding to an existing form: continue its pattern — don't renumber unrelated fields." }, "field_required": { "type": "integer", "enum": [ 0, 1 ], "description": "`0` or `1`. Forbidden when `field_type` ∈ {`HoneyPot`, `HTML`, `Tip`, `Button`} — wrapper refuses these combinations because the requirement can't be satisfied at submit. `Hidden` is allowed (its value comes from `field_text`)." }, "field_placeholder": { "type": "string" }, "field_ldesc": { "type": "string", "description": "Helper text rendered under the input field. Use for instructions / format hints (e.g. \"Use international format\")." }, "default_value": { "type": "string", "description": "Prefilled value the field loads with on render. Accepts a static value OR PHP (e.g. `<?php echo date('Y-m-d'); ?>`) — BD evaluates at render time on any field_type." }, "field_options": { "type": "string", "description": "For `field_type` ∈ {`Radio`, `Checkbox`, `Select`}: `system_name=>label,system_name=>label,...`. LHS submitted value, RHS displayed text. Comma and `=>` are reserved separators. `%%%token%%%` translations supported. Silently ignored on other field types." }, "field_input_view": { "type": "integer", "enum": [ 0, 1 ], "description": "Binary `0`/`1`. For readonly behavior, add the `readonly` CSS class to `input_class` (e.g. `form-control readonly`); do NOT use `field_input_view=2`." }, "field_display_view": { "type": "integer", "enum": [ 0, 1 ], "description": "Show submitted value on front-end record-detail pages. Binary `0`/`1`." }, "field_email_view": { "type": "integer", "enum": [ 0, 1 ], "description": "Include value in notification emails. Binary `0`/`1`." }, "field_search_view": { "type": "integer", "enum": [ 0, 1 ], "description": "Lead Previews flag — value visible in lead-preview cards before purchase. Applies to forms with `form_table=leads`. Schema default empty (treat as `0`); send explicitly when overriding." }, "field_grid_view": { "type": "integer", "enum": [ 0, 1 ], "description": "Table View flag — value renders in admin-dashboard / front-end data tables. Schema default `1`; send `0` to hide." }, "field_input_view_admin_only": { "type": "integer", "enum": [ 0, 1 ], "description": "Admin-only render flag. When `1`, field renders only when an admin is logged-in on the front end with admin-view enabled; members never see it. Use on `form_action_type=default` member-account forms. Default `0`." }, "json_meta": { "type": "string", "description": "JSON-stringified per-field metadata blob (UI rendering + validator config). See **Rule: Forms** § Field anatomy → `json_meta` for the canonical skeleton. Pass the full skeleton even when unused; BD's admin form-builder writes all keys. Validators in `field_validate.validators` only fire when `field_validator_enabled` is `\"1\"`." }, "input_class": { "type": "string", "description": "HTML `class=` attribute on the rendered input. **Required for `field_type=Button`** - without it, submit button renders unstyled.\n\nCanonical Button pattern: `btn btn-lg btn-block <variant>` where `<variant>` is a Bootstrap button class (`btn-primary`/`btn-secondary`/`btn-danger`/`btn-success`/`btn-warning`/`btn-info`/`btn-dark`) or a custom site class. Example: `btn btn-lg btn-block btn-secondary`.\n\nOptional on non-Button fields." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new formfield record. Writes live data.\n\n**Required:** `form_name`, `field_name`, `field_text`, `field_type`, `field_order`.\n\n**Canonical `field_name` for `form_table=website_contacts`:** `yourname` / `inquiry_email` / `phone` / `comments` (NOT `name` / `email` / `message` — those persist but don't surface in the admin inbox columns). Anything else = custom `field_name`. Full table at **Rule: Forms** § Form classes.\n\nSee **Rule: Forms** § Field anatomy for field shape, view-flag defaults, validators, and the canonical `json_meta` skeleton. § Form-level recipe covers the tail pattern. § Lead-match / § Member-dashboard cover special-case forms.\n\n**Wrapper-enforced refusals:** (1) `field_required=1` with `field_type` ∈ {`HoneyPot`, `HTML`, `Tip`, `Button`} — `Hidden` is allowed. (2) `field_type` not in the canonical enum (strict case match; `textarea` is the lone lowercase value). (3) `field_type=Hidden` with empty `field_name` or empty `field_text`. (4) Non-binary value on any of `field_required` / `field_input_view` / `field_display_view` / `field_email_view` / `field_search_view` / `field_grid_view` / `field_input_view_admin_only` (empty / omitted accepted — BD applies per-field defaults).\n\n**Agent pre-checks** (NOT wrapper-enforced): `field_name` uniqueness within form, single submit element per form. See **Rule: Forms** § Wrapper-enforced invariants → Agent-side responsibilities.\n\n**See also:** `updateFormField`, `listFormFields`.\n\n" } }, "/api/v2/form_fields/update": { "put": { "operationId": "updateFormField", "summary": "Update a form field", "tags": [ "Form Fields" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "field_id" ], "properties": { "field_id": { "type": "integer" }, "field_text": { "type": "string" }, "field_type": { "type": "string", "enum": [ "Checkbox", "Select", "Radio", "YesNo", "Custom", "Email", "HTML", "Button", "Textbox", "textarea", "Url", "Date", "DateTimeLocal", "File", "FroalaEditor", "FroalaEditorUserUpload", "FroalaEditorUserUploadPreMadeElem", "FroalaEditorAdmin", "Tip", "Hidden", "Country", "State", "Number", "Password", "Phone", "CountryCodePhone", "Pricebox", "ReCaptcha", "HoneyPot", "Category", "Years" ], "description": "Form field type. Copy spelling exactly — most are TitleCase but `textarea` is lowercase. Grouping + use cases at **Rule: Forms** § Field anatomy → Valid field_type values." }, "field_order": { "type": "integer", "description": "Display position (lower = earlier). New forms: multiples of 10 (10, 20, 30…), security tail included. Adding to an existing form: continue its pattern — don't renumber unrelated fields." }, "field_required": { "type": "integer", "enum": [ 0, 1 ], "description": "`0` or `1`. Forbidden when `field_type` ∈ {`HoneyPot`, `HTML`, `Tip`, `Button`} — wrapper refuses these combinations because the requirement can't be satisfied at submit. `Hidden` is allowed (its value comes from `field_text`)." }, "field_placeholder": { "type": "string" }, "field_ldesc": { "type": "string", "description": "Helper text rendered under the input field. Use for instructions / format hints (e.g. \"Use international format\")." }, "default_value": { "type": "string", "description": "Prefilled value the field loads with on render. Accepts a static value OR PHP (e.g. `<?php echo date('Y-m-d'); ?>`) — BD evaluates at render time on any field_type." }, "field_options": { "type": "string", "description": "For `field_type` ∈ {`Radio`, `Checkbox`, `Select`}: `system_name=>label,system_name=>label,...`. LHS submitted value, RHS displayed text. Comma and `=>` are reserved separators. `%%%token%%%` translations supported. Silently ignored on other field types." }, "field_input_view": { "type": "integer", "enum": [ 0, 1 ], "description": "Binary `0`/`1`. For readonly behavior, add the `readonly` CSS class to `input_class` (e.g. `form-control readonly`); do NOT use `field_input_view=2`." }, "field_display_view": { "type": "integer", "enum": [ 0, 1 ], "description": "Show submitted value on front-end record-detail pages. Binary `0`/`1`." }, "field_email_view": { "type": "integer", "enum": [ 0, 1 ], "description": "Include value in notification emails. Binary `0`/`1`." }, "field_search_view": { "type": "integer", "enum": [ 0, 1 ], "description": "Lead Previews flag — value visible in lead-preview cards before purchase. Applies to forms with `form_table=leads`." }, "field_grid_view": { "type": "integer", "enum": [ 0, 1 ], "description": "Table View flag — value renders in admin-dashboard / front-end data tables." }, "field_input_view_admin_only": { "type": "integer", "enum": [ 0, 1 ], "description": "Admin-only render flag. When `1`, field renders only when an admin is logged-in on the front end with admin-view enabled; members never see it. Use on `form_action_type=default` member-account forms. Default `0`." }, "json_meta": { "type": "string", "description": "JSON-stringified per-field metadata blob (UI rendering + validator config). See **Rule: Forms** § Field anatomy → `json_meta` for the canonical skeleton." }, "input_class": { "type": "string", "description": "HTML `class=` attribute. On `field_type=Button`, must be `btn btn-lg btn-block <variant>` - e.g. `btn btn-lg btn-block btn-secondary`. Variant is one of `btn-primary`/`btn-secondary`/`btn-danger`/`btn-success`/`btn-warning`/`btn-info`/`btn-dark`, OR a custom class targeted by site CSS. See `createFormField`." }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing formfield record by ID. Fields omitted are untouched. Writes live data.\n\n**Required:** `field_id`.\n\nWhen renaming `field_name` on a `form_table=website_contacts` form, use canonical names (`yourname` / `inquiry_email` / `phone` / `comments`) — see `createFormField` and **Rule: Forms** § Form classes.\n\nSee **Rule: Forms** § Field anatomy for field shape, view-flag defaults, validators, and the canonical `json_meta` skeleton.\n\n**Wrapper-enforced refusals:** (1) `field_required=1` with `field_type` ∈ {`HoneyPot`, `HTML`, `Tip`, `Button`} — `Hidden` is allowed. (2) `field_type` not in the canonical enum (strict case match; `textarea` is the lone lowercase value). (3) `field_type=Hidden` with empty `field_name` or empty `field_text`. (4) Non-binary value on any of `field_required` / `field_input_view` / `field_display_view` / `field_email_view` / `field_search_view` / `field_grid_view` / `field_input_view_admin_only` (empty / omitted accepted — BD applies per-field defaults).\n\n**Agent pre-checks** (NOT wrapper-enforced): `field_name` uniqueness within form, single submit element per form. See **Rule: Forms** § Wrapper-enforced invariants → Agent-side responsibilities.\n\n**See also:** `createFormField`, `deleteFormField`, `listFormFields`.\n\n" } }, "/api/v2/form_fields/delete": { "delete": { "operationId": "deleteFormField", "summary": "Delete a form field", "tags": [ "Form Fields" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "field_id" ], "properties": { "field_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a formfield record by ID. Destructive - cannot be undone via API.\n\n**Use when:** removing a field. Existing submission records may reference the old field name - data persists but becomes orphan metadata.\n\n**Required:** `field_id`.\n\n**See also:** `updateFormField` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/subscription_types/get": { "get": { "operationId": "listMembershipPlans", "summary": "List membership plans", "tags": [ "Membership Plans" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "name": "include_plan_config", "in": "query", "description": "Opt in to restore plan config fields: `sub_active`, `search_priority`, `auto_activate`, `status_after_upgrade`, `upgradable_membership`, `search_membership_permissions`, `photo_limit`, `style_limit`, `service_limit`, `location_limit`, all form/sidebar/email-template fields, `profile_layout`, `menu_name`, `data_settings`, `payment_default`, `hide_specialties`, `email_member`, `login_redirect`, `page_header`, `page_footer`, `display_ads`, `receive_messages`, `index_rule`, `nofollow_links`. Default stripped.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, { "name": "include_plan_display_flags", "in": "query", "description": "Opt in to restore profile-visibility toggles: `show_about`, `show_experience`, `show_education`, `show_background`, `show_affiliations`, `show_publications`, `show_awards`, `show_slogan`, `show_sofware`, `show_phone`, `seal_link`, `website_link`, `social_link`. Default stripped.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of membership-plan records. Read-only.\n\n**Use when:** discovering `subscription_id` values to use when creating members. Essential prerequisite for `createUser` - every member needs a valid `subscription_id`.\n\n**Lean-by-default:** always-kept = `subscription_id`, `subscription_name`, `subscription_type`, `profile_type`, `monthly_amount`, `yearly_amount`, `initial_amount`, `lead_price`, `searchable`. Heavy config (~40 fields) + display-visibility toggles (~13 fields) are stripped. Most 'pick a plan' workflows don't need them. Flags:\n\n- `include_plan_config=1` - restores config bundle (active/searchable toggles, limits, forms, sidebars, email templates, upgrade chain, payment defaults, etc.).\n- `include_plan_display_flags=1` - restores `show_*` profile-visibility toggles.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination**.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators**.\n\n**See also:** `getMembershipPlan` (single by ID).\n\n**Returns:** `{ status: \"success\", total, ..., message: [...records] }`." } }, "/api/v2/subscription_types/get/{subscription_id}": { "get": { "operationId": "getMembershipPlan", "summary": "Get a single membership plan", "tags": [ "Membership Plans" ], "parameters": [ { "name": "subscription_id", "in": "path", "required": true, "schema": { "type": "integer" } }, { "name": "include_plan_config", "in": "query", "description": "Opt in to restore plan config fields (limits, sidebars, forms, email templates, upgrade chain, display/payment settings). Default stripped.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, { "name": "include_plan_display_flags", "in": "query", "description": "Opt in to restore profile-visibility toggles (`show_about`, `show_experience`, `show_phone`, `seal_link`, `website_link`, `social_link`, etc.). Default stripped.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single membership-plan record. Read-only.\n\n**Use when:** fetching one plan's config. Same lean-by-default as `listMembershipPlans`.\n\n**Required:** `subscription_id`.\n\n**Lean shape:** always-kept = `subscription_id`, `subscription_name`, `subscription_type`, `profile_type`, `monthly_amount`, `yearly_amount`, `initial_amount`, `lead_price`, `searchable`. Opt in to restore:\n\n- `include_plan_config=1` - config bundle (limits, sidebars, forms, email templates, upgrade chain, payment defaults).\n- `include_plan_display_flags=1` - `show_*` profile-visibility toggles.\n\n**EAV-routed fields not merged:** `custom_checkout_url` (and any future EAV-routed plan fields) are stored in users_meta and NOT returned by this endpoint even with `include_plan_config=1`. Read via `listUserMeta database=subscription_types database_id=<subscription_id>` to fetch them.\n\n**See also:** `listMembershipPlans` (enumerate).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }`." } }, "/api/v2/subscription_types/create": { "post": { "operationId": "createMembershipPlan", "summary": "Create a membership plan", "tags": [ "Membership Plans" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "subscription_name", "profile_type" ], "properties": { "subscription_name": { "type": "string" }, "subscription_type": { "type": "string", "default": "member", "description": "Internal role identifier - distinct from `profile_type`. Default `\"member\"` (applied when omitted). Valid values NOT publicly documented by BD.\n\n**Omit on create** unless BD staff or admin UI gave you an authoritative value. For plan monetization model (paid/free/claim-listing) use `profile_type`, NOT this field." }, "profile_type": { "type": "string", "enum": [ "paid", "free", "claim" ], "description": "Plan monetization model. Documented values: `paid`, `free`, `claim`. **BD does NOT validate server-side** — values outside this set are stored verbatim with undefined render behavior (live observed: `\"individual\"` accepted on prior writes). Stick to documented values." }, "monthly_amount": { "type": "number" }, "yearly_amount": { "type": "number" }, "sub_active": { "type": "integer", "enum": [ 0, 1 ], "description": "Plan availability for NEW signups:\n- `1` = active (plan appears on public signup pages; new members can join)\n- `0` = inactive (plan hidden from new-signup flows; existing members on this plan keep it — grandfather behavior)\n\nUse this to retire a plan cleanly. Do NOT hack the signup widget markup to hide a plan." }, "searchable": { "type": "integer", "enum": [ 0, 1 ], "description": "Whether members on this plan appear in public member search results. `1`=visible in search, `0`=hidden. Use this to hide a membership tier from public listings without deactivating billing." }, "search_priority": { "type": "integer", "minimum": 0, "description": "Search-result display priority (integer). **Lower number = higher in results.** NOT a 0/1 boolean - numeric value determines ordering.\n\n- `0` = highest priority (top of public search results)\n\n- `1`, `2`, `3`, ... = progressively lower\n\nUse `0` for featured/premium tiers that must appear first. Higher numbers for standard/budget tiers. No upper bound." }, "payment_default": { "type": "string", "enum": [ "yearly", "monthly" ] }, "subscription_filename": { "type": "string", "description": "Optional URL slug for this plan's public page. Must be site-wide unique — duplicates cause URL routing conflicts. Pre-check with 5 filtered `list*` calls (each `property_value=<proposed>&property_operator==`): `listMembershipPlans`+`subscription_filename`, `listTopCategories`+`filename`, `listSubCategories`+`filename`, `listWebPages`+`filename`, `listUsers`+`filename`. Any hit = taken. Safe to leave blank." }, "custom_checkout_url": { "type": "string", "description": "Optional custom checkout URL for this plan. Stored in `users_meta` (`database=subscription_types`, `key=custom_checkout_url`); BD auto-routes extra non-direct-column fields to users_meta on create. Must be unique across plans. Pre-check: `listUserMeta(database=\"subscription_types\", key=\"custom_checkout_url\")` then client-filter for any row whose `value` matches yours. Any hit = taken. Safe to leave blank." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new membershipplan record. Writes live data.\n\n**Use when:** launching a new plan tier. Rare - usually configured in BD admin UI. Check `profile_type` carefully: `paid`, `free`, or `claim` - changing later affects billing and visibility.\n\n**Required:** `subscription_name`, `profile_type`.\n\n**Pre-check before create:** BD does NOT enforce uniqueness on `subscription_name`. Duplicate plan names confuse admins at signup-form configuration, billing reports, and member migration. Do a **server-side filter-find**: `listMembershipPlans property=subscription_name property_value=<proposed> property_operator==`. Zero rows = name free; >=1 row = taken. **Do NOT paginate unfiltered lists** - filtered lookup is one tiny response. If taken: reuse via `updateMembershipPlan`, OR ask the user, OR pick an alternate `subscription_name` and re-check. Never silently create a duplicate.\n\n**Enums:** `payment_default`: `yearly`, `monthly`.\n\n**Parameter interactions:**\n\n- `subscription_type` - typically `member` for standard member plans\n\n- `profile_type`: `paid`, `free`, or `claim` - controls how profile visibility and billing work\n\n- `monthly_amount` / `yearly_amount` - price in the site's currency\n\n- `sub_active=1` makes the plan available for new signups; `sub_active=0` grandfathers existing members only\n\n- `searchable=1` makes members on this plan findable in public search\n\n**See also:** `updateMembershipPlan` (modify existing).\n\n" } }, "/api/v2/subscription_types/update": { "put": { "operationId": "updateMembershipPlan", "summary": "Update a membership plan", "tags": [ "Membership Plans" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "subscription_id" ], "properties": { "subscription_id": { "type": "integer" }, "subscription_name": { "type": "string" }, "monthly_amount": { "type": "number" }, "yearly_amount": { "type": "number" }, "sub_active": { "type": "integer", "enum": [ 0, 1 ], "description": "Plan availability for NEW signups:\n- `1` = active (plan appears on public signup pages; new members can join)\n- `0` = inactive (plan hidden from new-signup flows; existing members on this plan keep it — grandfather behavior)\n\nUse this to retire a plan cleanly. Do NOT hack the signup widget markup to hide a plan." }, "searchable": { "type": "integer", "enum": [ 0, 1 ], "description": "Whether members on this plan appear in public member search results. `1`=visible in search, `0`=hidden. Use this to hide a membership tier from public listings without deactivating billing." }, "hide_initial_amount": { "type": "integer", "enum": [ 0, 1 ], "description": "Payment-cycle visibility toggle: hide the one-time/initial-amount option from PUBLIC checkout pages. `0`=shown publicly (default), `1`=hidden publicly; when hidden, the cycle is still available when an admin manually creates a subscription inside the BD admin area (admin-only)." }, "hide_monthly_amount": { "type": "integer", "enum": [ 0, 1 ], "description": "Payment-cycle visibility toggle: hide the monthly option from PUBLIC checkout pages. `0`=shown publicly (default), `1`=hidden publicly; when hidden, still available for admin-created subscriptions." }, "hide_quarterly_amount": { "type": "integer", "enum": [ 0, 1 ], "description": "Payment-cycle visibility toggle: hide the quarterly option from PUBLIC checkout pages. `0`=shown publicly (default), `1`=hidden publicly; when hidden, still available for admin-created subscriptions." }, "hide_semiyearly_amount": { "type": "integer", "enum": [ 0, 1 ], "description": "Payment-cycle visibility toggle: hide the semi-yearly (6-month) option from PUBLIC checkout pages. `0`=shown publicly (default), `1`=hidden publicly; when hidden, still available for admin-created subscriptions." }, "hide_yearly_amount": { "type": "integer", "enum": [ 0, 1 ], "description": "Payment-cycle visibility toggle: hide the yearly option from PUBLIC checkout pages. `0`=shown publicly (default), `1`=hidden publicly; when hidden, still available for admin-created subscriptions." }, "hide_biennially_amount": { "type": "integer", "enum": [ 0, 1 ], "description": "Payment-cycle visibility toggle: hide the biennially (2-year) option from PUBLIC checkout pages. `0`=shown publicly (default), `1`=hidden publicly; when hidden, still available for admin-created subscriptions." }, "hide_triennially_amount": { "type": "integer", "enum": [ 0, 1 ], "description": "Payment-cycle visibility toggle: hide the triennially (3-year) option from PUBLIC checkout pages. `0`=shown publicly (default), `1`=hidden publicly; when hidden, still available for admin-created subscriptions." }, "hide_billing_links": { "type": "integer", "enum": [ 0, 1 ], "description": "Visibility toggle for billing/transaction links in the member dashboard for this plan. `0`=show billing links (default), `1`=hide billing links (e.g. free plans or admin-managed-only plans where members shouldn't self-manage billing)." }, "hide_parent_accounts": { "type": "integer", "enum": [ 0, 1 ], "description": "Visibility toggle for parent-account elements when this plan supports sub-accounts. `0`=show, `1`=hide." }, "hide_reviews_rating_options": { "type": "integer", "enum": [ 0, 1 ], "description": "Visibility toggle for review rating controls on this plan's member profiles. `0`=show ratings UI, `1`=hide ratings UI." }, "hide_specialties": { "type": "integer", "enum": [ 0, 1 ], "description": "Visibility toggle for specialties/categories section on this plan's member profiles. `0`=show, `1`=hide." }, "hide_notifications": { "type": "integer", "enum": [ 0, 1 ], "description": "Visibility toggle for notification UI in the member dashboard for this plan. `0`=show, `1`=hide." }, "subscription_filename": { "type": "string", "description": "Optional URL slug for this plan's public page. Must be site-wide unique — duplicates cause URL routing conflicts. Pre-check with 5 filtered `list*` calls (each `property_value=<proposed>&property_operator==`): `listMembershipPlans`+`subscription_filename` (ignore hits on this plan's own `subscription_id`), `listTopCategories`+`filename`, `listSubCategories`+`filename`, `listWebPages`+`filename`, `listUsers`+`filename`. Any OTHER hit = taken. Safe to leave blank." }, "custom_checkout_url": { "type": "string", "description": "Optional custom checkout URL for this plan. Stored in `users_meta` (`database=subscription_types`, `key=custom_checkout_url`); MCP wrapper handles EAV upsert on update. Must be unique across all plans. Pre-check: `listUserMeta(database=\"subscription_types\", key=\"custom_checkout_url\")` then client-filter for any row whose `value` matches yours; ignore the row where `database_id` equals this plan's `subscription_id`. Any OTHER hit = taken. Safe to leave blank." }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing membershipplan record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** adjusting pricing (`monthly_amount`/`yearly_amount`), toggling `sub_active` (new-signup availability), or changing feature flags like `searchable`, `photo_limit`. Changes apply to NEW signups; existing members on this plan keep their original terms unless manually migrated.\n\n**Required:** `subscription_id`.\n\n**See also:** `createMembershipPlan` (add new), `deleteMembershipPlan` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/subscription_types/delete": { "delete": { "operationId": "deleteMembershipPlan", "summary": "Delete a membership plan", "tags": [ "Membership Plans" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "subscription_id" ], "properties": { "subscription_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a membershipplan record by ID. Destructive - cannot be undone via API.\n\n**Use when:** retiring a plan that has no members (or all its members have been migrated). Members with matching `subscription_id` become orphaned - migrate them first via `updateUser`.\n\n**Required:** `subscription_id`.\n\n**See also:** `updateMembershipPlan` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/menus/get": { "get": { "operationId": "listMenus", "summary": "List menus", "tags": [ "Menus" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of menu records. Read-only.\n\n**Use when:** enumerating navigation menus on the site (main menu, footer menu, sidebar, etc.). For items within a menu use `listMenuItems` with `menu_id` filter.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getMenu` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/menus/get/{menu_id}": { "get": { "operationId": "getMenu", "summary": "Get a single menu", "tags": [ "Menus" ], "parameters": [ { "name": "menu_id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single menu record. Read-only.\n\n**Use when:** fetching one menu's metadata. Child items are fetched separately.\n\n**Required:** `menu_id`.\n\n**See also:** `listMenus` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/menus/create": { "post": { "operationId": "createMenu", "summary": "Create a menu", "tags": [ "Menus" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "menu_name", "menu_title" ], "properties": { "menu_name": { "type": "string", "maxLength": 35 }, "menu_title": { "type": "string" }, "menu_location": { "type": "string" }, "menu_div_id": { "type": "string", "maxLength": 60 }, "menu_div_class": { "type": "string", "maxLength": 60 }, "menu_div_css": { "type": "string" }, "menu_div_code": { "type": "string" }, "menu_effects": { "type": "string", "maxLength": 60 }, "menu_active": { "type": "integer", "enum": [ 0, 1 ] } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new menu record. Writes live data.\n\n**Use when:** adding a new navigation container. After creating the container, add entries via `createMenuItem` using the returned `menu_id`.\n\n**Required:** `menu_name`, `menu_title`.\n\n**Pre-check before create:** BD does NOT enforce uniqueness on `menu_name`. Duplicates cause the wrong menu to render wherever the menu is referenced. Do a **server-side filter-find**: `listMenus property=menu_name property_value=<proposed> property_operator==`. Zero rows = name free; >=1 row = taken. **Do NOT paginate unfiltered lists** - filtered lookup is one tiny response. If taken: reuse via `updateMenu`, OR ask the user, OR pick an alternate `menu_name` and re-check. Never silently create a duplicate.\n\n**Parameter interactions:**\n\n- `menu_name` (max 35 chars) - the internal identifier\n\n- `menu_title` - the visible heading\n\n- `menu_active`: `0`=Inactive, `1`=Active\n\n- After creating the container, add entries via `createMenuItem` using the returned `menu_id`\n\n**See also:** `updateMenu` (modify existing).\n\n" } }, "/api/v2/menus/update": { "put": { "operationId": "updateMenu", "summary": "Update a menu", "tags": [ "Menus" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "menu_id" ], "properties": { "menu_id": { "type": "integer" }, "menu_name": { "type": "string" }, "menu_title": { "type": "string" }, "menu_active": { "type": "integer" }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing menu record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** renaming a menu, toggling `menu_active`, or adjusting its CSS/HTML wrapper attributes.\n\n**Required:** `menu_id`.\n\n**See also:** `createMenu` (add new), `deleteMenu` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/menus/delete": { "delete": { "operationId": "deleteMenu", "summary": "Delete a menu", "tags": [ "Menus" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "menu_id" ], "properties": { "menu_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a menu record by ID. Destructive - cannot be undone via API.\n\n**Use when:** removing a menu container. Child items (`menu_items` rows with matching `menu_id`) become orphaned - delete them first.\n\n**Required:** `menu_id`.\n\n**See also:** `updateMenu` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/menu_items/get": { "get": { "operationId": "listMenuItems", "summary": "List menu items", "tags": [ "Menu Items" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of menuitem records. Read-only.\n\n**Use when:** enumerating items in a menu - always filter by `menu_id`. Use `master_id` filter for sub-menu items.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getMenuItem` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/menu_items/get/{menu_item_id}": { "get": { "operationId": "getMenuItem", "summary": "Get a single menu item", "tags": [ "Menu Items" ], "parameters": [ { "name": "menu_item_id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single menuitem record. Read-only.\n\n**Use when:** editing one specific menu entry.\n\n**Required:** `menu_item_id`.\n\n**See also:** `listMenuItems` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/menu_items/create": { "post": { "operationId": "createMenuItem", "summary": "Create a menu item", "tags": [ "Menu Items" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "menu_id", "menu_name", "menu_link", "master_id", "menu_order" ], "properties": { "menu_id": { "type": "integer", "description": "Parent menu ID" }, "menu_name": { "type": "string", "description": "Display text" }, "menu_link": { "type": "string", "description": "URL or path" }, "master_id": { "type": "integer", "description": "0 for top-level, parent item ID for sub-items" }, "menu_order": { "type": "integer" }, "menu_active": { "type": "integer", "enum": [ 0, 1 ] }, "menu_target": { "type": "string", "enum": [ "_blank", "_self" ] }, "menu_class": { "type": "string" }, "menu_icon": { "type": "string" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new menuitem record. Writes live data.\n\n**Use when:** adding a nav link to an existing menu. Parent `menu_id` must exist. For nested items pass `master_id=<parent menu_item_id>`; for top-level pass `0`. `menu_order` determines display position (lower = earlier).\n\n**Required:** `menu_id`, `menu_name`, `menu_link`, `master_id`, `menu_order`.\n\n**Enums:** `menu_active`: `0`=Inactive, `1`=Active.\n\n**Parameter interactions:**\n\n- `menu_id` - parent menu container (from `createMenu` or `listMenus`)\n\n- `master_id` - `0` for top-level items; for nested items, the ID of the parent menu item\n\n- `menu_order` - display position within the parent menu (integer, lower = earlier)\n\n- `menu_target`: `_blank` (new tab) or `_self` (same window)\n\n**See also:** `updateMenuItem` (modify existing).\n\n" } }, "/api/v2/menu_items/update": { "put": { "operationId": "updateMenuItem", "summary": "Update a menu item", "tags": [ "Menu Items" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "menu_item_id" ], "properties": { "menu_item_id": { "type": "integer" }, "menu_name": { "type": "string" }, "menu_link": { "type": "string" }, "menu_order": { "type": "integer" }, "menu_active": { "type": "integer" }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing menuitem record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** renaming, re-linking (change `menu_link`), reordering (change `menu_order`), or hiding (change `menu_active=0`).\n\n**Required:** `menu_item_id`.\n\n**See also:** `createMenuItem` (add new), `deleteMenuItem` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/menu_items/delete": { "delete": { "operationId": "deleteMenuItem", "summary": "Delete a menu item", "tags": [ "Menu Items" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "menu_item_id" ], "properties": { "menu_item_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a menuitem record by ID. Destructive - cannot be undone via API.\n\n**Use when:** removing a single menu entry.\n\n**Required:** `menu_item_id`.\n\n**See also:** `updateMenuItem` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/list_services/get": { "get": { "operationId": "listSubCategories", "summary": "List services (sub-categories)", "tags": [ "Services" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/include_category_schema" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of SUB-level member categories (services). Read-only.\n\n**Lean by default:** each row keeps `service_id`, `profession_id` (parent Top Category link), `master_id` (parent Sub Category for sub-sub), `name`, `filename`. Strips `desc`, `keywords`, `image`, `icon`, `sort_order`, `lead_price`, `revision_timestamp`. Pass `include_category_schema=1` to restore all category metadata. Hierarchy is always visible so agents can traverse top -> sub -> sub-sub without opt-in.\n\n\nSub Categories are level 2 of the 3-tier member classification (e.g., \"Sushi\" under \"Restaurants\"). Each has a `profession_id` pointing at its parent Top Category. `master_id` points at a parent Sub Category for sub-sub-category nesting (`master_id=0` = directly under a Top Category). Backed by BD's `list_services` table.\n\n**Use when:** enumerating sub-categories (services) - always filter by `profession_id` to scope to one Top Category, otherwise you get all sub-cats across all tops (noisy). For sub-sub nesting, `master_id` filter narrows further.\n\n**Permission note - platform gap:** this endpoint (`/api/v2/list_services/*`) is NOT in BD's public Swagger spec, so the admin's API key permissions UI does NOT auto-generate a toggle for it. The admin's \"Services\" toggle gates the Swagger-documented `/api/v2/service/*` endpoints (a DIFFERENT legacy table) - enabling that toggle does NOT grant access here. On 403: admin must manually INSERT a row into `bd_api_key_permissions` for `endpoint_path='/api/v2/list_services/get'` (and the singular `/api/v2/list_services/get/{service_id}` for `getSubCategory`). Do NOT substitute `/api/v2/service/*` - different table, inconsistent data.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getSubCategory` (single by ID), `listTopCategories` (parents), `createSubCategory` (add new).\n\n**Returns:** `{ status: \"success\", total, ..., message: [...records] }`. Each record has `service_id`, `name`, `desc`, `profession_id`, `master_id`, `filename`, `keywords`, `sort_order`, `lead_price`, `image`.\n\n\n\n**How a member gets classified on their public profile:**\n\n- `users_data.profession_id` -> points at a single Top Category (the member's primary classification; shown in URL slug)\n\n- `users_data.services` -> CSV of Sub Category IDs the member is tagged with (multiple allowed; simpler than the join table)\n\n- `rel_services` rows (Member ↔ Sub Category links) -> used when you need per-link metadata like `avg_price`, `specialty`, `num_completed`. Optional; most sites use just the CSV field.\n\n**Sub-sub-categories:** `createSubCategory` with `master_id=<parent service_id>` creates a Sub Category nested under another Sub Category (a \"sub-sub\"). `master_id=0` (default) means the Sub Category sits directly under a Top Category (the `profession_id`).\n\n**There is NO `createProfession` or `createService` tool in this MCP** — those are BD's internal table names. Use `createTopCategory` / `createSubCategory` instead (BD's table-name → tool-name mapping is documented in **Rule: Table to endpoint**).\n\n\n" } }, "/api/v2/list_services/get/{service_id}": { "get": { "operationId": "getSubCategory", "summary": "Get a single service", "tags": [ "Services" ], "parameters": [ { "name": "service_id", "in": "path", "required": true, "schema": { "type": "integer" } }, { "$ref": "#/components/parameters/include_category_schema" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single SUB-level member category (service) by `service_id`. Read-only.\n\n**Lean by default:** keeps `service_id`, `profession_id`, `master_id`, `name`, `filename`. Strips SEO metadata (`desc`, `keywords`, `image`, `icon`, `sort_order`, `lead_price`, `revision_timestamp`). Pass `include_category_schema=1` to restore.\n\n\n**Use when:** fetching one sub-category by `service_id` - usually after discovering it via `listSubCategories`.\n\n**Required:** `service_id` (path).\n\n**See also:** `listSubCategories` (enumerate; filter by `profession_id` for scope), `getTopCategory` (fetch parent Top by `profession_id`).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }`.\n\n\n\n**How a member gets classified on their public profile:**\n\n- `users_data.profession_id` -> points at a single Top Category (the member's primary classification; shown in URL slug)\n\n- `users_data.services` -> CSV of Sub Category IDs the member is tagged with (multiple allowed; simpler than the join table)\n\n- `rel_services` rows (Member ↔ Sub Category links) -> used when you need per-link metadata like `avg_price`, `specialty`, `num_completed`. Optional; most sites use just the CSV field.\n\n**Sub-sub-categories:** `createSubCategory` with `master_id=<parent service_id>` creates a Sub Category nested under another Sub Category (a \"sub-sub\"). `master_id=0` (default) means the Sub Category sits directly under a Top Category (the `profession_id`).\n\n**There is NO `createProfession` or `createService` tool in this MCP** — those are BD's internal table names. Use `createTopCategory` / `createSubCategory` instead (BD's table-name → tool-name mapping is documented in **Rule: Table to endpoint**).\n\n\n" } }, "/api/v2/list_services/create": { "post": { "operationId": "createSubCategory", "summary": "Create a service", "tags": [ "Services" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "name", "profession_id" ], "properties": { "name": { "type": "string" }, "profession_id": { "type": "integer", "description": "Parent category ID" }, "desc": { "type": "string", "description": "Short internal taxonomy-row label. **Even if the user says \"description\" - this is NOT an SEO description.** Not a meta-tag surface, not Google-ranking copy, not the H1/intro on the public category search page. Most BD themes don't render this field.\n\nFor ANY SEO task on a category or sub-category - \"write a description that ranks,\" \"improve SEO,\" \"add meta tags,\" \"write intro copy\" - create a WebPage with `seo_type=profile_search_results` and the matching slug instead (see `createWebPage`). Short internal blurb only here." }, "filename": { "type": "string", "description": "URL slug. Must be unique across web pages, top categories, sub categories, plan public URLs, and member profile slugs (wrapper auto-rejects collisions; pick a different slug or rename the conflict first)." }, "keywords": { "type": "string", "description": "Fuzzy-search synonyms for on-site category matching - NOT SEO meta-keywords. Comma-separated single words (no spaces): synonyms, abbreviations, slang, common misspellings. Example for `Doctor`: `doc,physician,md,medic,gp,specialist`. ~5-10 max. Skip SEO phrases like `doctor near me` - those aren't fuzzy matchers. Optional." }, "sort_order": { "type": "integer" }, "lead_price": { "type": "number" }, "master_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new SUB-level member category under an existing Top Category. Writes live data.\n\nA Sub Category is level 2 of the 3-tier member classification. It MUST have a parent Top Category (via `profession_id`). It may optionally sit under another Sub Category (for sub-sub-category nesting, via `master_id`). Backed by BD's `list_services` table.\n\n**Use when:** explicitly adding a sub-category BEFORE assigning members to it. If creating/updating a user who needs a sub-category that doesn't exist yet, on `createUser` just include the name in `services` - BD auto-creates it. On `updateUser`, pass `create_new_categories=1` to allow inline creation. For sub-sub nesting pass `master_id=<parent service_id>`; otherwise set `master_id=0` for direct-under-Top.\n\n**Required:** `name`, `profession_id`.\n\n**Pre-check before create:** BD does NOT enforce uniqueness on `filename` (URL slug) or `name` - but uniqueness IS scoped per-parent (two sub-cats with the same `filename` under different `profession_id` is fine; same `filename` under the SAME `profession_id` is not). Do a **server-side filter-find**: `listSubCategories property=filename property_value=<proposed> property_operator==`, then filter results by the intended `profession_id`. Zero rows under that parent = slug free; >=1 row = taken (URL collision - wrong sub-cat page resolves). **Do NOT paginate unfiltered lists** - filtered lookup is one tiny response. If taken: reuse via `updateSubCategory`, OR ask the user, OR pick an alternate `filename` and re-check. **Wrapper safety net:** on a missed pre-check, the wrapper auto-suffixes `filename` on collision (`-1`...`-20`) and surfaces the suffix in the response. Pre-checking still preferred — auto-suffix surprises the caller in URL-sensitive workflows.\n\n**Parameter guidance:**\n\n- `name` - human-readable (e.g. \"Sushi\")\n\n- `profession_id` - the parent Top Category's ID (from `listTopCategories` or `createTopCategory`)\n\n- `master_id` - for SUB-SUB-CATEGORY nesting, pass the parent Sub Category's ID; default 0 means \"directly under the Top Category\"\n\n- `filename` - URL-slug form; `desc`, `keywords`, `sort_order`, `lead_price`, `image` - all optional\n\n**See also:** `updateSubCategory` (modify), `listSubCategories` (list), `createTopCategory` (create parent).\n\n**Writes live data:** changes are immediately visible on the public site.\n\n**Returns:** `{ status: \"success\", message: {...createdRecord} }` including `service_id`. Use that to assign members via `updateUser.services` (CSV) or `createMemberSubCategoryLink`.\n\n\n\n**How a member gets classified on their public profile:**\n\n- `users_data.profession_id` -> points at a single Top Category (the member's primary classification; shown in URL slug)\n\n- `users_data.services` -> CSV of Sub Category IDs the member is tagged with (multiple allowed; simpler than the join table)\n\n- `rel_services` rows (Member ↔ Sub Category links) -> used when you need per-link metadata like `avg_price`, `specialty`, `num_completed`. Optional; most sites use just the CSV field.\n\n**Sub-sub-categories:** `createSubCategory` with `master_id=<parent service_id>` creates a Sub Category nested under another Sub Category (a \"sub-sub\"). `master_id=0` (default) means the Sub Category sits directly under a Top Category (the `profession_id`).\n\n**There is NO `createProfession` or `createService` tool in this MCP** — those are BD's internal table names. Use `createTopCategory` / `createSubCategory` instead (BD's table-name → tool-name mapping is documented in **Rule: Table to endpoint**).\n\n\n" } }, "/api/v2/list_services/update": { "put": { "operationId": "updateSubCategory", "summary": "Update a service", "tags": [ "Services" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "service_id" ], "properties": { "service_id": { "type": "integer" }, "name": { "type": "string" }, "desc": { "type": "string", "description": "Short internal taxonomy-row label. **Even if the user says \"description\" - this is NOT an SEO description.** Not a meta-tag surface, not Google-ranking copy, not the H1/intro on the public category search page. Most BD themes don't render this field.\n\nFor ANY SEO task on a category or sub-category - \"write a description that ranks,\" \"improve SEO,\" \"add meta tags,\" \"write intro copy\" - create a WebPage with `seo_type=profile_search_results` and the matching slug instead (see `createWebPage`). Short internal blurb only here." }, "profession_id": { "type": "integer" }, "filename": { "type": "string", "description": "URL slug. Renaming orphans any `seo_type=profile_search_results` web page bound to the OLD filename — that page can't query this category anymore and renders empty. Before renaming, run `listWebPages property=filename property_value=<old-filename>`; if a profile_search_results page exists, rename it in the same operation (and consider `createRedirect` for SEO continuity)." }, "keywords": { "type": "string", "description": "Fuzzy-search synonyms for on-site category matching - NOT SEO meta-keywords. Comma-separated single words (no spaces): synonyms, abbreviations, slang, common misspellings. Example for `Doctor`: `doc,physician,md,medic,gp,specialist`. ~5-10 max. Skip SEO phrases like `doctor near me` - those aren't fuzzy matchers. Optional." }, "sort_order": { "type": "integer" }, "lead_price": { "type": "number" }, "master_id": { "type": "integer" }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing SUB-level member category by `service_id`. Fields omitted are untouched. Writes live data.\n\n**Use when:** renaming, re-parenting (change `profession_id` to move under a different Top, or `master_id` to re-nest as sub-sub), or adjusting `lead_price` for per-service lead pricing.\n\n**Required:** `service_id`.\n\n**Filename rename caveat:** if the existing `filename` has a `seo_type=profile_search_results` web page bound to it, renaming this category orphans that page. The wrapper rejects renames that would orphan a bound page — rename or delete the bound page first, then rename the category.\n\n**Parameter notes:**\n\n- Change `profession_id` to move this Sub Category to a different parent Top Category\n\n- Change `master_id` to re-nest as a sub-sub-category (non-zero) or flatten to direct-under-Top (0)\n\n**See also:** `createSubCategory` (add new), `deleteSubCategory` (remove).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }`.\n\n\n\n**How a member gets classified on their public profile:**\n\n- `users_data.profession_id` -> points at a single Top Category (the member's primary classification; shown in URL slug)\n\n- `users_data.services` -> CSV of Sub Category IDs the member is tagged with (multiple allowed; simpler than the join table)\n\n- `rel_services` rows (Member ↔ Sub Category links) -> used when you need per-link metadata like `avg_price`, `specialty`, `num_completed`. Optional; most sites use just the CSV field.\n\n**Sub-sub-categories:** `createSubCategory` with `master_id=<parent service_id>` creates a Sub Category nested under another Sub Category (a \"sub-sub\"). `master_id=0` (default) means the Sub Category sits directly under a Top Category (the `profession_id`).\n\n**There is NO `createProfession` or `createService` tool in this MCP** — those are BD's internal table names. Use `createTopCategory` / `createSubCategory` instead (BD's table-name → tool-name mapping is documented in **Rule: Table to endpoint**).\n\n\n" } }, "/api/v2/list_services/delete": { "delete": { "operationId": "deleteSubCategory", "summary": "Delete a service", "tags": [ "Services" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "service_id" ], "properties": { "service_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a SUB-level member category by `service_id`. Destructive - cannot be undone via API.\n\n**Use when:** removing an unused sub-category. Any member with this `service_id` in their `users_data.services` CSV or in `rel_services` rows becomes orphaned - clean those up first.\n\n**Required:** `service_id`.\n\n**Destructive:** confirm intent. Members whose `users_data.services` CSV contains this ID will have an orphan reference. Any Member ↔ Sub Category links (`rel_services`) pointing at this service_id also become orphaned.\n\n**Bound-page caveat:** if this category's `filename` has a `seo_type=profile_search_results` web page bound to it, deleting the category orphans that page (it'll render empty — no category to query). The wrapper rejects deletes that would orphan a bound page — delete or repurpose the bound page first.\n\n**See also:** `updateSubCategory` (modify without removing).\n\n**Returns:** `{ status: \"success\", message: \"list_services record was deleted\" }`.\n\n\n\n**How a member gets classified on their public profile:**\n\n- `users_data.profession_id` -> points at a single Top Category (the member's primary classification; shown in URL slug)\n\n- `users_data.services` -> CSV of Sub Category IDs the member is tagged with (multiple allowed; simpler than the join table)\n\n- `rel_services` rows (Member ↔ Sub Category links) -> used when you need per-link metadata like `avg_price`, `specialty`, `num_completed`. Optional; most sites use just the CSV field.\n\n**Sub-sub-categories:** `createSubCategory` with `master_id=<parent service_id>` creates a Sub Category nested under another Sub Category (a \"sub-sub\"). `master_id=0` (default) means the Sub Category sits directly under a Top Category (the `profession_id`).\n\n**There is NO `createProfession` or `createService` tool in this MCP** — those are BD's internal table names. Use `createTopCategory` / `createSubCategory` instead (BD's table-name → tool-name mapping is documented in **Rule: Table to endpoint**).\n\n\n" } }, "/api/v2/rel_services/get": { "get": { "operationId": "listMemberSubCategoryLinks", "summary": "List user-service relationships", "tags": [ "User Services" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of MEMBER ↔ SUB CATEGORY links. Read-only.\n\nEach record links a member (`user_id`) to a Sub Category (`service_id`) with per-link metadata: `avg_price`, `specialty`, `num_completed`, `date`. This is level 3 of the member-taxonomy relationship. Backed by BD's `rel_services` table.\n\n**Use when:** auditing per-service-link metadata (prices, specialty flags, completion counts) across members. Filter by `user_id` to see one member's links, `service_id` to see everyone offering that service. For simpler \"is this member tagged with this sub-cat\" checks, the `users_data.services` CSV on the member record is cheaper.\n\n**When to use this vs. the simpler `users_data.services` CSV field:** use this resource when you need PER-LINK metadata (pricing tier, specialty flag, completion counter). If you just want \"this member is tagged with these Sub Categories\" with no extra data, set `updateUser.services` (CSV of service IDs) instead.\n\n**Pagination + filter/sort:** standard.\n\n**See also:** `getMemberSubCategoryLink`, `createMemberSubCategoryLink`, `listSubCategories` (available Sub Categories), `updateUser` (sets the `services` CSV for simpler cases).\n\n**Returns:** `{ status: \"success\", ..., message: [...records] }`. Each has `rel_id`, `user_id`, `service_id`, `date`, `avg_price`, `num_completed`, `specialty`.\n\n\n\n**How a member gets classified on their public profile:**\n\n- `users_data.profession_id` -> points at a single Top Category (the member's primary classification; shown in URL slug)\n\n- `users_data.services` -> CSV of Sub Category IDs the member is tagged with (multiple allowed; simpler than the join table)\n\n- `rel_services` rows (Member ↔ Sub Category links) -> used when you need per-link metadata like `avg_price`, `specialty`, `num_completed`. Optional; most sites use just the CSV field.\n\n**Sub-sub-categories:** `createSubCategory` with `master_id=<parent service_id>` creates a Sub Category nested under another Sub Category (a \"sub-sub\"). `master_id=0` (default) means the Sub Category sits directly under a Top Category (the `profession_id`).\n\n**There is NO `createProfession` or `createService` tool in this MCP** — those are BD's internal table names. Use `createTopCategory` / `createSubCategory` instead (BD's table-name → tool-name mapping is documented in **Rule: Table to endpoint**).\n\n\n" } }, "/api/v2/rel_services/get/{rel_id}": { "get": { "operationId": "getMemberSubCategoryLink", "summary": "Get a single user-service relationship", "tags": [ "User Services" ], "parameters": [ { "name": "rel_id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single Member ↔ Sub Category link by `rel_id`. Read-only.\n\n**Use when:** you have a `rel_id` and need the full link row. Rare - most workflows query by `user_id` or `service_id` via `listMemberSubCategoryLinks`.\n\n**Required:** `rel_id`.\n\n**See also:** `listMemberSubCategoryLinks` (enumerate, filter by `user_id` or `service_id`).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }`.\n\n\n\n**How a member gets classified on their public profile:**\n\n- `users_data.profession_id` -> points at a single Top Category (the member's primary classification; shown in URL slug)\n\n- `users_data.services` -> CSV of Sub Category IDs the member is tagged with (multiple allowed; simpler than the join table)\n\n- `rel_services` rows (Member ↔ Sub Category links) -> used when you need per-link metadata like `avg_price`, `specialty`, `num_completed`. Optional; most sites use just the CSV field.\n\n**Sub-sub-categories:** `createSubCategory` with `master_id=<parent service_id>` creates a Sub Category nested under another Sub Category (a \"sub-sub\"). `master_id=0` (default) means the Sub Category sits directly under a Top Category (the `profession_id`).\n\n**There is NO `createProfession` or `createService` tool in this MCP** — those are BD's internal table names. Use `createTopCategory` / `createSubCategory` instead (BD's table-name → tool-name mapping is documented in **Rule: Table to endpoint**).\n\n\n" } }, "/api/v2/rel_services/create": { "post": { "operationId": "createMemberSubCategoryLink", "summary": "Link a service to a user", "tags": [ "User Services" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "user_id", "service_id" ], "properties": { "user_id": { "type": "integer" }, "service_id": { "type": "integer" }, "date": { "type": "string", "description": "Format: `YYYYMMDDHHmmss` in the site's timezone. BD silently truncates other formats, corrupting the value. Optional — omit unless backfilling historical data." }, "avg_price": { "type": "number" }, "specialty": { "type": "integer", "enum": [ 0, 1 ] } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new Member ↔ Sub Category link with optional metadata. Writes live data.\n\nLinks an existing member (`user_id`) to an existing Sub Category (`service_id`) with per-link metadata (pricing, specialty). This is the richer alternative to setting `users_data.services` CSV via `updateUser` - use this when you need per-link data.\n\n**Use when:** the simpler `users_data.services` CSV isn't rich enough - you need per-link `avg_price`, `specialty=1`, or `num_completed`. For plain \"tag this member with this sub-cat\" use `updateUser` with `services=<service_id>` instead.\n\n**Required:** `user_id`, `service_id`.\n\n**Pre-check before create (PAIR uniqueness):** BD does NOT enforce uniqueness on the `(user_id, service_id)` pair in `rel_services`. Linking the same member to the same Sub Category twice produces two rel_services rows, double-counts the member in that Sub Category's listing widgets, and leaves per-link metadata (specialty/avg_price) ambiguous - which row wins? **Filter-find pattern (single-field server filter + client-side intersect - the server does not yet honor array-syntax multi-condition filters):** call `listMemberSubCategoryLinks property=user_id property_value=<proposed user_id> property_operator==` to narrow to all rel_services rows for that member, then CLIENT-SIDE filter to rows where `service_id=<proposed service_id>`. Zero results after client-side step = link free; >=1 = already linked. If the link already exists: update it via `updateMemberSubCategoryLink` (e.g. to set `specialty=1` or `avg_price`), OR skip the create (idempotent). Never silently double-link the same member to the same Sub Category.\n\n**Parameter guidance:**\n\n- `user_id` - member (from `listUsers` / `searchUsers`)\n\n- `service_id` - Sub Category (from `listSubCategories`)\n\n- `avg_price` - decimal, the member's price for this service\n\n- `specialty` - `0` or `1` flags this Sub Category as a specialty offering on the member's profile\n\n- `num_completed` - counter of jobs/projects completed in this Sub Category\n\n- `date` - YYYYMMDDHHmmss timestamp\n\n**See also:** `updateUser` with `services=\"<csv>\"` (simpler, no per-link metadata), `listSubCategories`, `getMemberSubCategoryLink`.\n\n**Writes live data:** appears on the member's public profile immediately.\n\n**Returns:** `{ status: \"success\", message: {...createdRecord} }` with `rel_id`.\n\n\n\n**How a member gets classified on their public profile:**\n\n- `users_data.profession_id` -> points at a single Top Category (the member's primary classification; shown in URL slug)\n\n- `users_data.services` -> CSV of Sub Category IDs the member is tagged with (multiple allowed; simpler than the join table)\n\n- `rel_services` rows (Member ↔ Sub Category links) -> used when you need per-link metadata like `avg_price`, `specialty`, `num_completed`. Optional; most sites use just the CSV field.\n\n**Sub-sub-categories:** `createSubCategory` with `master_id=<parent service_id>` creates a Sub Category nested under another Sub Category (a \"sub-sub\"). `master_id=0` (default) means the Sub Category sits directly under a Top Category (the `profession_id`).\n\n**There is NO `createProfession` or `createService` tool in this MCP** — those are BD's internal table names. Use `createTopCategory` / `createSubCategory` instead (BD's table-name → tool-name mapping is documented in **Rule: Table to endpoint**).\n\n\n" } }, "/api/v2/rel_services/update": { "put": { "operationId": "updateMemberSubCategoryLink", "summary": "Update a user-service relationship", "tags": [ "User Services" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "rel_id" ], "properties": { "rel_id": { "type": "integer" }, "avg_price": { "type": "number" }, "specialty": { "type": "integer" }, "num_completed": { "type": "integer" }, "date": { "type": "string", "description": "Format: `YYYYMMDDHHmmss` in the site's timezone. BD silently truncates other formats, corrupting the value. Optional — omit unless backfilling historical data." }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update a Member ↔ Sub Category link by `rel_id`. Fields omitted are untouched. Writes live data.\n\n**Use when:** adjusting per-link metadata - member's price for this service, specialty flag, completion counter.\n\n**Required:** `rel_id`.\n\n**Updatable fields:** `avg_price`, `specialty`, `num_completed`, `date`.\n\n**See also:** `createMemberSubCategoryLink` (add new), `deleteMemberSubCategoryLink` (remove).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }`.\n\n\n\n**How a member gets classified on their public profile:**\n\n- `users_data.profession_id` -> points at a single Top Category (the member's primary classification; shown in URL slug)\n\n- `users_data.services` -> CSV of Sub Category IDs the member is tagged with (multiple allowed; simpler than the join table)\n\n- `rel_services` rows (Member ↔ Sub Category links) -> used when you need per-link metadata like `avg_price`, `specialty`, `num_completed`. Optional; most sites use just the CSV field.\n\n**Sub-sub-categories:** `createSubCategory` with `master_id=<parent service_id>` creates a Sub Category nested under another Sub Category (a \"sub-sub\"). `master_id=0` (default) means the Sub Category sits directly under a Top Category (the `profession_id`).\n\n**There is NO `createProfession` or `createService` tool in this MCP** — those are BD's internal table names. Use `createTopCategory` / `createSubCategory` instead (BD's table-name → tool-name mapping is documented in **Rule: Table to endpoint**).\n\n\n" } }, "/api/v2/rel_services/delete": { "delete": { "operationId": "deleteMemberSubCategoryLink", "summary": "Remove a service from a user", "tags": [ "User Services" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "rel_id" ], "properties": { "rel_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a Member ↔ Sub Category link by `rel_id`. Destructive - cannot be undone via API.\n\nRemoves the member's link to this Sub Category in the `rel_services` join table. Does NOT remove the member from `users_data.services` CSV if the service_id is listed there - update that separately via `updateUser` if needed.\n\n**Use when:** removing a specific link row. Does NOT update the `users_data.services` CSV - that's a separate field; update it via `updateUser` if the service_id is also listed there.\n\n**Required:** `rel_id`.\n\n**Returns:** `{ status: \"success\", message: \"rel_services record was deleted\" }`.\n\n\n\n**How a member gets classified on their public profile:**\n\n- `users_data.profession_id` -> points at a single Top Category (the member's primary classification; shown in URL slug)\n\n- `users_data.services` -> CSV of Sub Category IDs the member is tagged with (multiple allowed; simpler than the join table)\n\n- `rel_services` rows (Member ↔ Sub Category links) -> used when you need per-link metadata like `avg_price`, `specialty`, `num_completed`. Optional; most sites use just the CSV field.\n\n**Sub-sub-categories:** `createSubCategory` with `master_id=<parent service_id>` creates a Sub Category nested under another Sub Category (a \"sub-sub\"). `master_id=0` (default) means the Sub Category sits directly under a Top Category (the `profession_id`).\n\n**There is NO `createProfession` or `createService` tool in this MCP** — those are BD's internal table names. Use `createTopCategory` / `createSubCategory` instead (BD's table-name → tool-name mapping is documented in **Rule: Table to endpoint**).\n\n\n" } }, "/api/v2/users_photo/get": { "get": { "operationId": "listUserPhotos", "summary": "List user photos", "tags": [ "User Photos" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of userphoto records. Read-only.\n\n**Use when:** enumerating photos attached to members (profile, logo, cover). Filter by `user_id`.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getUserPhoto` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/users_photo/get/{photo_id}": { "get": { "operationId": "getUserPhoto", "summary": "Get a single user photo", "tags": [ "User Photos" ], "parameters": [ { "name": "photo_id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single userphoto record. Read-only.\n\n**Use when:** fetching one photo record by `photo_id`.\n\n**Required:** `photo_id`.\n\n**See also:** `listUserPhotos` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/users_photo/create": { "post": { "operationId": "createUserPhoto", "summary": "Create a user photo", "tags": [ "User Photos" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "user_id", "file", "type" ], "properties": { "user_id": { "type": "integer" }, "file": { "type": "string", "description": "Image filename" }, "type": { "type": "string", "enum": [ "logo", "photo", "cover_photo" ] } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new userphoto record. Writes live data.\n\n**Use when:** attaching a new photo record to a member. The image file must already exist in site storage (upload via admin or `auto_image_import`).\n\n**Required:** `user_id`, `file`, `type`.\n\n**Parameter interactions:**\n\n- `user_id` - the member\n\n- `type` - slot: `logo`, `photo`, or `cover_photo`\n\n- `file` - image filename (must already exist in site storage)\n\n**See also:** `updateUserPhoto` (modify existing).\n\n" } }, "/api/v2/users_photo/update": { "put": { "operationId": "updateUserPhoto", "summary": "Update a user photo", "tags": [ "User Photos" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "photo_id" ], "properties": { "photo_id": { "type": "integer" }, "type": { "type": "string", "enum": [ "logo", "photo", "cover_photo" ] }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing userphoto record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** changing a photo's `type` slot (logo/photo/cover_photo).\n\n**Required:** `photo_id`.\n\n**Enums:** `type`: `logo`, `photo`, `cover_photo`.\n\n**See also:** `createUserPhoto` (add new), `deleteUserPhoto` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/users_photo/delete": { "delete": { "operationId": "deleteUserPhoto", "summary": "Delete a user photo", "tags": [ "User Photos" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "photo_id" ], "properties": { "photo_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a userphoto record by ID. Destructive - cannot be undone via API.\n\n**Use when:** removing a photo attachment. For member image management consider `updateUser` with `images_action=remove_all` / `remove_cover_image` / etc. instead - that covers the member-record side of image cleanup.\n\n**Required:** `photo_id`.\n\n**See also:** `updateUserPhoto` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/users_meta/get": { "get": { "operationId": "listUserMeta", "summary": "List user metadata records", "tags": [ "User Metadata" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "name": "database", "in": "query", "description": "Parent table filter (e.g. `list_seo`, `users_data`, `data_posts`). First-class shortcut - the MCP wrapper translates it into BD's multi-condition filter syntax. Pair with `database_id` to target all EAV rows for one parent record, or with `key` to find one field across parents. The safety guard requires 2 of `(database, database_id, key)` on every query.", "schema": { "type": "string" } }, { "name": "database_id", "in": "query", "description": "Parent record primary key filter. Pair with `database` (required - same `database_id` can exist on multiple parent tables) to target one parent's EAV rows, or with `key` to find one specific field. The safety guard rejects queries that send this alone.", "schema": { "type": "integer" } }, { "name": "key", "in": "query", "description": "EAV key/field-name filter (e.g. `hero_content_overlay_opacity`, `search_membership_permissions`). Pair with `database` (scope to one parent table) or with `database_id` (one specific row). The safety guard rejects this alone.", "schema": { "type": "string" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of users_meta records (EAV key/value table). Read-only.\n\n**Use when:** enumerating metadata rows (key/value pairs) attached to a parent record in any BD table.\n\n**IDENTITY RULE - `(database, database_id)` is ONE atomic compound identity, not two independent fields.** The same numeric `database_id` routinely points at UNRELATED rows on different parent tables - any integer ID can exist as a PK on multiple parent tables simultaneously. A `database_id=<n>`-only query will return a MIX of rows from every parent table where that integer happens to be a PK (even low IDs like `1` return hundreds of rows spanning 2+ parent tables). **Always pair `database=<parent_table>` WITH `database_id=<id>` whenever reading, writing, updating, or deleting users_meta.** Pass `database`, `database_id`, and `key` as first-class query params; the MCP wrapper translates them into BD's multi-condition filter syntax so server-side scoping IS accurate — no client-side post-filter needed. The safety guard requires at least 2 of `(database, database_id, key)` on every read; single-filter queries are rejected. **Do NOT mix first-class filters with the generic `property`/`property_value` style in the same call** — pick one style. Never act on a partial-identity result - misidentifying a row can silently corrupt or destroy unrelated resource metadata on another table.\n\n**Commonly-seen `database` values (BD may accept other table names with users_meta rows - prefer these for known resources; if the user names an unfamiliar table, GET first to verify it actually has meta rows before writing):** `users_data`, `deleted_users_data`, `data_posts`, `list_seo`, `subscription_types`, `list_professions`, `list_services`, `rel_services`, `tags`, `tag_groups`, `rel_tags`, `leads`, `lead_matches`, `forms`, `form_fields`, `users_reviews`, `menus`, `menu_items`, `data_widgets`, `email_templates`, `301_redirects`, `data_categories`, `smart_lists`, `users_clicks`, `unsubscribe_list`.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter:** prefer the first-class `database` / `database_id` / `key` params (see **Rule: users_meta identity**). The generic `property` / `property_value` / `property_operator` + `order_column` / `order_type` flow also works as a fallback and counts toward the 2-of-3 guard when `property` is one of the three target keys — but do not mix the two styles in the same call.\n\n**See also:** `getUserMeta` (single record by ID), `updateUserMeta`, `deleteUserMeta`.\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record includes `meta_id`, `database`, `database_id`, `key`, `value`, `date_added`, `revision_timestamp`.\n\n**Note on duplicates:** BD does NOT enforce a uniqueness constraint on `(database, database_id, key)` - the same field can have multiple rows (observed live). Read-layer merge uses last-write-wins, but stored data can bloat. When updating, consider patching ALL matching rows, or deleting duplicates first.\n\n" } }, "/api/v2/users_meta/get/{meta_id}": { "get": { "operationId": "getUserMeta", "summary": "Get a single metadata record", "tags": [ "User Metadata" ], "parameters": [ { "name": "meta_id", "in": "path", "required": true, "schema": { "type": "integer" } }, { "name": "database", "in": "query", "description": "OPTIONAL — your expected parent table name (e.g. `list_seo`). Accepted for intent documentation; verification is agent-side (compare `message[0].database` in the response). Recommended on any `getUserMeta` preceding a write to the users_meta table.", "schema": { "type": "string" } }, { "name": "database_id", "in": "query", "description": "OPTIONAL — your expected parent record PK. Same intent-documentation convention as `database`. Agent-side verification: compare `message[0].database_id` before acting on the row.", "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single usermeta record. Read-only.\n\n**Use when:** fetching one metadata row by `meta_id`.\n\n**Required:** `meta_id`.\n\n**Identity check before downstream writes:** Before using this row's `meta_id` for any subsequent `updateUserMeta`/`deleteUserMeta` call, confirm the response's `database` and `database_id` fields BOTH match the parent record you intend to modify. The same `database_id` can exist across unrelated parent tables (`users_data`, `list_seo`, `subscription_types`, `data_posts`, etc.) - blindly passing a `meta_id` forward without verifying its `(database, database_id)` pair can silently corrupt or destroy data on an unrelated table. Optional `database` and `database_id` query params are accepted for documentation/intent — the actual verification is agent-side (compare `message[0].database` / `message[0].database_id` to what you expected before acting).\n\n**See also:** `listUserMeta` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/users_meta/create": { "post": { "operationId": "createUserMeta", "summary": "Create a metadata record — DELIBERATELY HIDDEN FROM AGENTS. BD auto-seeds users_meta rows on every parent-record create for each EAV field the parent supports. Exposing createUserMeta to AI agents invites misuse — writing orphan rows with the wrong database_id, duplicating auto-seeded rows, or creating rows for keys BD doesn't recognize on that parent table. Agents should only `listUserMeta` → `updateUserMeta` if found. Before re-exposing this tool, read the CHANGELOG entry and think twice.", "tags": [ "User Metadata" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "database", "database_id", "key", "value" ], "properties": { "database": { "type": "string", "description": "Target table name" }, "database_id": { "type": "integer", "description": "Record ID in target table" }, "key": { "type": "string" }, "value": { "type": "string" }, "date_added": { "type": "string", "description": "Format: `YYYYMMDDHHmmss` in the site's timezone. BD silently truncates other formats, corrupting the value. Optional — omit unless backfilling historical data." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Hidden from agent catalog. users_meta row creation happens only via the wrapper's EAV auto-route on parent tools — see **Rule: EAV auto-route** for the canonical list. Use `updateUserMeta` (existing rows) or `deleteUserMeta` (orphan cleanup)." } }, "/api/v2/users_meta/update": { "put": { "operationId": "updateUserMeta", "summary": "Update a metadata record", "tags": [ "User Metadata" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "meta_id", "value", "database", "database_id" ], "properties": { "meta_id": { "type": "integer" }, "value": { "type": "string" }, "database": { "type": "string", "description": "**REQUIRED** - parent table name this meta row attaches to (e.g. `list_seo`, `users_data`, `subscription_types`, `data_posts`). MUST match the `database` value stored on the row being updated.\n\nHard safety rule: the same `database_id` number can exist across multiple parent tables. Scoping with `(database, database_id)` together prevents cross-table metadata corruption. See **Rule: users_meta identity**." }, "database_id": { "type": "integer", "description": "REQUIRED - PK of the parent record this meta row is attached to. MUST match the `database_id` value stored on the row being updated. Same safety rule as `database`: the same numeric ID can belong to unrelated rows on different tables." }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing users_meta record's `value` by `meta_id`. Fields omitted are untouched. Writes live data.\n\n**IDENTITY RULE - `(database, database_id)` is ONE atomic compound identity, not two fields. ALWAYS confirm BOTH match the intended parent BEFORE updating.** A users_meta row's identity is `(database, database_id, key)`. The same numeric `database_id` routinely belongs to UNRELATED rows on different parent tables. Never use a `meta_id` blindly from an unscoped list - always either (a) `getUserMeta(meta_id)` first and inspect `database`+`database_id`, OR (b) obtain the `meta_id` from a `listUserMeta` whose results have been CLIENT-SIDE filtered to the intended `database`+`database_id` pair. Misidentifying a row silently overwrites unrelated resource metadata.\n\n**Use when:**\n\n- Changing a metadata value on any BD table row that was previously created via `createUserMeta`.\n\n- **For `list_seo` (web page) EAV fields, use `updateWebPage` directly — the wrapper auto-routes them through `users_meta` for you.** This `updateUserMeta` endpoint is for changing existing values on OTHER BD tables (e.g. `users_data`, `subscription_types`, `data_posts` custom meta), or for the rare case where a `list_seo` EAV field doesn't persist after `updateWebPage` — file that as a wrapper bug rather than working around it here.\n\n**Workflow:** find the `meta_id` by calling `listUserMeta` with filter `database=<table>`, `database_id=<parent_id>`, `key=<field>`. Then call this endpoint with `meta_id`, `value`, and the same `database` + `database_id` you used for lookup. If no row exists, the row cannot be created via this endpoint — see **Rule: users_meta writes**. Never guess `meta_id`; 404 = stop, not retry.\n\n**Required:** `meta_id`, `value`, `database`, `database_id`. All four - always. The identity pair (`database`, `database_id`) is enforced at the schema level to prevent cross-table corruption.\n\n**See also:** `listUserMeta` (find the `meta_id` by filter), `deleteUserMeta` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/users_meta/delete": { "delete": { "operationId": "deleteUserMeta", "summary": "Delete a metadata record", "tags": [ "User Metadata" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "meta_id", "database", "database_id" ], "properties": { "meta_id": { "type": "integer" }, "database": { "type": "string", "description": "REQUIRED - parent table name (e.g. `list_seo`, `users_data`). Must match the row's stored `database` field. Prevents accidental cross-table deletion since the same `database_id` can exist in multiple parent tables." }, "database_id": { "type": "integer", "description": "REQUIRED - parent record PK. Must match the row's stored `database_id` field. Agents MUST verify both `database` and `database_id` before calling delete to prevent destroying unrelated metadata on other tables with the same ID number." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a users_meta record by `meta_id`. DESTRUCTIVE - cannot be undone via API.\n\n**HARD RULE - `(database, database_id)` is ONE atomic compound identity. Verify BOTH of the row BEFORE deleting. Destructive mistakes on users_meta are unrecoverable.** A users_meta row's identity is `(database, database_id, key)`. The same numeric `database_id` routinely belongs to UNRELATED rows on different parent tables - an integer ID may simultaneously be a WebPage's `seo_id`, a member's `user_id`, a post's `post_id`, and a plan's `subscription_id`. BEFORE calling this endpoint: (1) call `getUserMeta(meta_id)` or retain the row's full object from a prior `listUserMeta` response; (2) confirm the row's `database` value matches the table you intend to clean up. For batch orphan-cleanup after a parent delete: list by the parent's `database_id`, then CLIENT-SIDE filter to ONLY rows where `database` equals the parent table's name BEFORE deleting any `meta_id`. **NEVER loop-delete by `database_id` alone** - you WILL destroy unrelated resource metadata (member data, plan metadata, page settings) that happen to share the same numeric ID on other tables.\n\n**Use when:** removing a specific metadata row, OR cleaning up orphan meta rows after a parent record is deleted (BD does not cascade-delete users_meta when a parent is removed - it's the agent's job to find and delete the orphan rows surgically).\n\n**Required:** `meta_id`, `database`, `database_id`. All three - always. The identity pair (`database`, `database_id`) is enforced at the schema level to prevent cross-table destruction.\n\n**Post-parent-delete cleanup workflow (safe pattern):**\n1. `listUserMeta` with filter `database_id=<deleted parent's id>`\n2. In the returned array, filter CLIENT-SIDE to ONLY rows where `database` equals the parent table's name (e.g. `list_seo` for a deleted WebPage)\n3. For each filtered `meta_id`, call `deleteUserMeta(meta_id, database=<parent table>, database_id=<parent id>)` - all three required\n4. Never skip step 2 - the same `database_id` can belong to unrelated rows on other tables\n\n**See also:** `updateUserMeta` (modify without removing), `listUserMeta` (enumerate with filter).\n\n**Returns:** `{ status: \"success\", message: \"users_meta record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/tags/get": { "get": { "operationId": "listTags", "summary": "List tags", "tags": [ "Tags" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of tag records. Read-only.\n\n**Use when:** enumerating member tags, fetching tag names for display, or building a tag-management UI. Tags are lightweight labels attached to members, different from categories (which are taxonomy).\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getTag` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/tags/get/{id}": { "get": { "operationId": "getTag", "summary": "Get a single tag", "tags": [ "Tags" ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single tag record. Read-only.\n\n**Use when:** fetching one tag by ID.\n\n**Required:** `id`.\n\n**See also:** `listTags` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/tags/create": { "post": { "operationId": "createTag", "summary": "Create a tag", "tags": [ "Tags" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "tag_name", "group_tag_id" ], "properties": { "tag_name": { "type": "string" }, "group_tag_id": { "type": "integer", "description": "Tag group this tag belongs to (from `listTagGroups`). **BD does NOT enforce FK** — passing `0` or a nonexistent group_tag_id is silently accepted and produces an orphan tag (live observed). Verify the group exists before passing." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new tag record. Writes live data.\n\n**Use when:** adding a new tag. Group (`group_tag_id`) must exist - discover via `listTagGroups`.\n\n**Required:** `tag_name`, `group_tag_id`.\n\n**`added_by` is wrapper-managed:** the audit-trail `added_by` field is hardcoded to `0` by the wrapper on every create. Not exposed as an input.\n\n**Duplicate `tag_name` silent-accept:** BD does NOT enforce a uniqueness constraint on `tag_name` within a `group_tag_id`. Two `createTag` calls with the same `tag_name` + `group_tag_id` both succeed and produce two rows with different `tag_id`s. Downstream `createTagRelationship` calls then become ambiguous (which of the two tags?). **Recommended pre-check pattern:** call `listTags` with `property=tag_name&property_value=<name>&property_operator==` (optionally filtered further by `group_tag_id`) BEFORE create. If a match exists, reuse that `tag_id` rather than creating a duplicate.\n\n**Parameter interactions:**\n\n- `tag_name` - the visible label\n\n- `group_tag_id` - tag group from `listTagGroups`\n\n**See also:** `updateTag` (modify existing).\n\n**Returns:** `{ status: \"success\", message: {...createdRecord} }` - includes the server-assigned ID. Use this ID for follow-up operations.\n\n" } }, "/api/v2/tags/update": { "put": { "operationId": "updateTag", "summary": "Update a tag", "tags": [ "Tags" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "id" ], "properties": { "id": { "type": "integer" }, "tag_name": { "type": "string" }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing tag record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** renaming a tag without losing the tag-to-member relationships.\n\n**Required:** `id`.\n\n**`updated_by` is wrapper-managed:** the audit-trail `updated_by` field is hardcoded to `0` by the wrapper on every update. Not exposed as an input.\n\n**See also:** `createTag` (add new), `deleteTag` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/tags/delete": { "delete": { "operationId": "deleteTag", "summary": "Delete a tag", "tags": [ "Tags" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "id" ], "properties": { "id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a tag record by ID. Destructive - cannot be undone via API.\n\n**Use when:** removing a tag entirely. Tag-relationships (`rel_tags`) pointing at it may orphan.\n\n**Required:** `id`.\n\n**See also:** `updateTag` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/tag_groups/get": { "get": { "operationId": "listTagGroups", "summary": "List tag groups", "tags": [ "Tag Groups" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of taggroup records. Read-only.\n\n**Use when:** discovering the tag groupings before creating tags - each tag belongs to a group.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getTagGroup` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/tag_groups/get/{id}": { "get": { "operationId": "getTagGroup", "summary": "Get a single tag group", "tags": [ "Tag Groups" ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single taggroup record. Read-only.\n\n**Use when:** fetching one tag group by ID.\n\n**Required:** `id`.\n\n**See also:** `listTagGroups` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/tag_groups/create": { "post": { "operationId": "createTagGroup", "summary": "Create a tag group", "tags": [ "Tag Groups" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "group_tag_name", "added_by", "updated_by" ], "properties": { "group_tag_name": { "type": "string" }, "added_by": { "type": "integer" }, "updated_by": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new taggroup record. Writes live data.\n\n**Use when:** organizing tags into new themes (e.g., \"Skill Level\", \"Service Area\"). Rare.\n\n**Required:** `group_tag_name`, `added_by`, `updated_by`.\n\n**Pre-check before create:** BD does NOT enforce uniqueness on `group_tag_name`. Duplicate group names cause tag-manager ambiguity (admins can't tell which group a tag belongs to) and break filters that select by group name. Do a **server-side filter-find**: `listTagGroups property=group_tag_name property_value=<proposed> property_operator==`. Zero rows = name free; >=1 row = taken. **Do NOT paginate unfiltered lists** - filtered lookup is one tiny response. If taken: reuse via `updateTagGroup`, OR ask the user, OR pick an alternate `group_tag_name` and re-check. Never silently create a duplicate.\n\n**See also:** `updateTagGroup` (modify existing).\n\n" } }, "/api/v2/tag_groups/update": { "put": { "operationId": "updateTagGroup", "summary": "Update a tag group", "tags": [ "Tag Groups" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "id" ], "properties": { "id": { "type": "integer" }, "group_tag_name": { "type": "string" }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing taggroup record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** renaming a tag group.\n\n**Required:** `id`.\n\n**See also:** `createTagGroup` (add new), `deleteTagGroup` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/tag_groups/delete": { "delete": { "operationId": "deleteTagGroup", "summary": "Delete a tag group", "tags": [ "Tag Groups" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "id" ], "properties": { "id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a taggroup record by ID. Destructive - cannot be undone via API.\n\n**Use when:** removing a group - child tags orphan; delete or re-group them first.\n\n**Required:** `id`.\n\n**See also:** `updateTagGroup` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/tag_types/get": { "get": { "operationId": "listTagTypes", "summary": "List tag types", "tags": [ "Tag Types" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of tagtype records. Read-only.\n\n**Use when:** enumerating the tag-type classifiers on this site.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getTagType` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/tag_types/get/{id}": { "get": { "operationId": "getTagType", "summary": "Get a single tag type", "tags": [ "Tag Types" ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single tagtype record. Read-only.\n\n**Use when:** one tag type by ID.\n\n**Required:** `id`.\n\n**See also:** `listTagTypes` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/rel_tags/get": { "get": { "operationId": "listTagRelationships", "summary": "List tag relationships", "tags": [ "Tag Relationships" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of tagrelationship records. Read-only.\n\n**Use when:** auditing which tags are attached to which records. Filter by tag or by target record.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getTagRelationship` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/rel_tags/get/{id}": { "get": { "operationId": "getTagRelationship", "summary": "Get a single tag relationship", "tags": [ "Tag Relationships" ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single tagrelationship record. Read-only.\n\n**Use when:** one relationship row by ID.\n\n**Required:** `id`.\n\n**See also:** `listTagRelationships` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/rel_tags/create": { "post": { "operationId": "createTagRelationship", "summary": "Create a tag relationship", "tags": [ "Tag Relationships" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "tag_id", "object_id", "tag_type_id", "added_by" ], "properties": { "tag_id": { "type": "string", "description": "Integer PK of the tag (`tags.id`) passed as a string per the underlying `varchar(500)` column. The schema column comment says \"name of the tag\" — that's legacy and misleading; BD stores the integer ID stringified. Discover via `listTags`." }, "object_id": { "type": "integer", "description": "Primary key of the record being tagged. Which table it lives in depends on tag_type_id - look up via listTagTypes. For tag_type_id=1 (Users), object_id is a user_id from users_data." }, "tag_type_id": { "type": "integer", "description": "Tag type classifier ID - determines which TABLE the `object_id` references. Call `listTagTypes` first to see the `tag_type_id` -> `table_relation` mapping.\n\nExample: `tag_type_id=1` usually means Users (`table_relation=users_data`), so `object_id` would be a `user_id`. Other tag types may target widgets, menus, forms." }, "added_by": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new tagrelationship record. Writes live data.\n\n**Use when:** attaching an existing tag to a record (member, post, etc.). The alternative for members is setting the `member_tags` field via `updateUser` with `member_tag_action=1`.\n\n**Required:** `tag_id`, `object_id`, `tag_type_id`, `added_by`.\n\n**Pre-check before create (TRIPLE uniqueness):** BD does NOT enforce uniqueness on the `(tag_id, object_id, tag_type_id)` triple. Attaching the same tag to the same object twice produces two rel_tag rows, which inflates tag counts on admin reports and can cause some widgets to render the same tag chip twice on the same record. **Filter-find pattern (single-field server filter + client-side intersect - the server does not yet honor array-syntax multi-condition filters):** call `listTagRelationships property=tag_id property_value=<proposed tag_id> property_operator==` to narrow to all rows for that tag, then CLIENT-SIDE filter to rows where `object_id=<proposed object_id>` AND `tag_type_id=<proposed tag_type_id>`. Zero results after client-side intersect = link free; >=1 = already attached. If the link already exists: skip the create (idempotent - the tag is already on the object). Never silently double-link.\n\n**Parameter interactions:**\n\n- Attaches an existing tag to an existing record. Both sides must exist - use `listTags` and the target resource's list to discover IDs\n\n**See also:** `updateTagRelationship` (modify existing).\n\n**`tag_type_id` + `object_id` - how to target the right record (from BD `tag_types` table):**\n\nThe `tag_type_id` determines WHICH resource/table the `object_id` lives in. Discover mapping via `listTagTypes` - each tag type row has a `table_relation` field naming its target table. Example mapping:\n\n| tag_type_id | type_name | Target table (table_relation) | What object_id references |\n|---|---|---|---|\n| 1 | Users | `users_data` | `user_id` |\n| (other rows) | (other types) | e.g. `data_widgets`, `menus`, `forms` | That table's primary key |\n\n**Process:** call `listTagTypes` first to see the `tag_type_id` -> `table_relation` mapping on your site, then pick the appropriate `tag_type_id` and supply the matching record's PK as `object_id`. Widgets, menus, and forms all support tags via the same `tag_type_id` + `object_id` lookup pattern.\n\n" } }, "/api/v2/rel_tags/update": { "put": { "operationId": "updateTagRelationship", "summary": "Update a tag relationship", "tags": [ "Tag Relationships" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "id" ], "properties": { "id": { "type": "integer" }, "object_id": { "type": "integer", "description": "Primary key of the record being tagged. Which table it lives in depends on tag_type_id - look up via listTagTypes. For tag_type_id=1 (Users), object_id is a user_id from users_data." }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing tagrelationship record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** adjusting a tag-relationship record's metadata.\n\n**Required:** `id`.\n\n**See also:** `createTagRelationship` (add new), `deleteTagRelationship` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/rel_tags/delete": { "delete": { "operationId": "deleteTagRelationship", "summary": "Delete a tag relationship", "tags": [ "Tag Relationships" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "id" ], "properties": { "id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a tagrelationship record by ID. Destructive - cannot be undone via API.\n\n**Use when:** detaching a tag from a record. Note: if the member has `member_tags` CSV set, update that separately too.\n\n**Required:** `id`.\n\n**See also:** `updateTagRelationship` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/smart_lists/get": { "get": { "operationId": "listSmartLists", "summary": "List smart lists", "tags": [ "Smart Lists" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of smartlist records. Read-only.\n\n**Use when:** enumerating saved dynamic filter configurations the admin has created - these back the BD admin's saved-filter UI for members, leads, reviews, etc.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getSmartList` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n" } }, "/api/v2/smart_lists/get/{smart_list_id}": { "get": { "operationId": "getSmartList", "summary": "Get a single smart list", "tags": [ "Smart Lists" ], "parameters": [ { "name": "smart_list_id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single smartlist record. Read-only.\n\n**Use when:** fetching one saved filter's config.\n\n**Required:** `smart_list_id`.\n\n**See also:** `listSmartLists` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/smart_lists/create": { "post": { "operationId": "createSmartList", "summary": "Create a smart list", "tags": [ "Smart Lists" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "smart_list_name", "smart_list_type", "smart_list_created_by" ], "properties": { "smart_list_name": { "type": "string" }, "smart_list_type": { "type": "string", "enum": [ "members", "forms_inbox", "leads", "reviews", "transaction", "newsletter" ] }, "smart_list_created_by": { "type": "integer", "description": "Admin user ID" }, "smart_list_query_params": { "type": "string", "description": "Filter criteria — format depends on `smart_list_type`:\n\n- **newsletter** - pass a URL string (used directly as `href`)\n- **all other types** (members, leads, reviews, transaction, forms_inbox) - pass a JSON string of key-value filter pairs like `{\"subscription_id\":\"1\",\"active\":\"1\"}`\n- **no filters** - pass `\"NA\"`\n\nBackend encrypts internally - do NOT pre-encrypt client-side." }, "schedule": { "type": "string", "description": "Recurrence frequency" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new smartlist record. Writes live data.\n\n**Use when:** programmatically saving a filter configuration for later reuse. `smart_list_type` determines the data source (`members`, `leads`, `reviews`, etc.).\n\n**Required:** `smart_list_name`, `smart_list_type`, `smart_list_created_by`.\n\n**Pre-check before create:** BD does NOT enforce uniqueness on `smart_list_name`. Duplicate list names mean admins and other tools can't tell the lists apart in the Smart Lists manager, and automations that look up a list by name will bind to the wrong record. Do a **server-side filter-find**: `listSmartLists property=smart_list_name property_value=<proposed> property_operator==`. Zero rows = name free; >=1 row = taken. **Do NOT paginate unfiltered lists** - filtered lookup is one tiny response. If taken: reuse via `updateSmartList`, OR ask the user, OR pick an alternate `smart_list_name` and re-check. Never silently create a duplicate.\n\n**Parameter interactions:**\n\n- `smart_list_type` - data source (see Enums)\n\n- `smart_list_created_by` - admin user ID creating the list\n\n- `smart_list_query_params` - JSON/string of filter criteria specific to the chosen type\n\n- `schedule` - recurrence if the list should auto-refresh\n\n**See also:** `updateSmartList` (modify existing).\n\n**`smart_list_query_params` format depends on `smart_list_type`:**\n\n- For `smart_list_type=newsletter`: store a URL string (admin view uses it directly as an href link - no filter semantics).\n\n- For ALL other types (`members`, `leads`, `reviews`, `transaction`, `forms_inbox`): pass a **JSON string of filter key-value pairs**, e.g. `{\"subscription_id\":\"1\",\"active\":\"1\"}`. The backend encrypts it internally before storing.\n\n- If empty / no filters: pass `\"NA\"` (the controller defaults missing values to this).\n\nThe API accepts the value as a plain string; BD handles the internal encryption. Don't pre-encrypt client-side - you'll get double-encrypted garbage. Use the JSON-key-value format for filterable types.\n\n" } }, "/api/v2/smart_lists/update": { "put": { "operationId": "updateSmartList", "summary": "Update a smart list", "tags": [ "Smart Lists" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "smart_list_id" ], "properties": { "smart_list_id": { "type": "integer" }, "smart_list_name": { "type": "string" }, "smart_list_modified_by": { "type": "integer" }, "smart_list_query_params": { "type": "string", "description": "Filter criteria — format depends on `smart_list_type`:\n\n- **newsletter** - pass a URL string (used directly as `href`)\n- **all other types** (members, leads, reviews, transaction, forms_inbox) - pass a JSON string of key-value filter pairs like `{\"subscription_id\":\"1\",\"active\":\"1\"}`\n- **no filters** - pass `\"NA\"`\n\nBackend encrypts internally - do NOT pre-encrypt client-side." }, "schedule": { "type": "string", "description": "Recurrence frequency" }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing smartlist record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** editing the filter criteria or schedule on a saved list.\n\n**Required:** `smart_list_id`.\n\n**See also:** `createSmartList` (add new), `deleteSmartList` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/smart_lists/delete": { "delete": { "operationId": "deleteSmartList", "summary": "Delete a smart list", "tags": [ "Smart Lists" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "smart_list_id" ], "properties": { "smart_list_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a smartlist record by ID. Destructive - cannot be undone via API.\n\n**Use when:** removing a saved filter configuration.\n\n**Required:** `smart_list_id`.\n\n**See also:** `updateSmartList` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/sidebars/get": { "get": { "operationId": "listSidebars", "summary": "List custom sidebars", "description": "Paginated enumeration of CUSTOM sidebars defined on this site. Read-only in this MCP (create/update/delete deliberately omitted - sidebars are layout infrastructure; changes belong in the BD admin UI).\n\n**Use when:** an agent needs to set `form_name` on a WebPage (the sidebar assignment) and wants to verify a custom sidebar name exists on this site before using it.\n\n**Important - this endpoint returns ONLY custom sidebars.** It does NOT return the Master Default Sidebars that are seeded in BD's master database and always valid on every site. Those are hardcoded in BD core and are NOT rows in the `sidebars` table. See **Rule: Sidebars** for the canonical Master Default list (use those names verbatim in `form_name`) and the agent workflow for matching a user-named sidebar against masters first, then customs from this endpoint, then asking the user if neither matches.\n\n**Returns:** rows with `sidebar_id`, `name` (display name - this is the VALUE to pass to `form_name`), `desc`, `active` (`1`/`0`), `separator`, `css`, `script`, `short_code`, `type`, `div_id`, `div_class`, `revision_timestamp`.\n\n**Pagination + filter/sort:** standard.\n\n", "tags": [ "Sidebars" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/property" }, { "$ref": "#/components/parameters/property_value" }, { "$ref": "#/components/parameters/property_operator" }, { "$ref": "#/components/parameters/order_column" }, { "$ref": "#/components/parameters/order_type" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } } } }, "/api/v2/sidebars/get/{sidebar_id}": { "get": { "operationId": "getSidebar", "summary": "Get a single custom sidebar", "description": "Fetch a single custom sidebar by `sidebar_id`. Read-only.\n\n**Required:** `sidebar_id` (path).\n\n**Only returns custom sidebars.** Master defaults are not rows in this table — see **Rule: Sidebars** for the canonical Master Default list; use those names directly in `form_name` without looking them up.\n\n**Returns:** `{ status: \"success\", message: [{...record}] }`.\n\n", "tags": [ "Sidebars" ], "parameters": [ { "name": "sidebar_id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } } } }, "/api/v2/site_info/get": { "get": { "operationId": "getSiteInfo", "summary": "Get site-level identity, locale, currency, and brand-image URLs", "description": "Returns the site's own identity and locale context — what kind of directory this is, who it serves, the formatting conventions to respect, and the URLs of branding assets. Read-only, no params. **Call this once on the first BD task of a conversation and cache for the session; the values rarely change mid-conversation.**\n\n**Response shape:** `{ status: \"success\", message: { website_id, website_name, website_phone, full_url, profession, industry, primary_country, language, timezone, date_format, distance_format, website_currency, currency_prefix, currency_suffix, currency_format, currency_decimal_divider, currency_thousand_divider, brand_images_relative: {...}, brand_images_absolute: {...} } }`.\n\n**Field semantics to know:**\n\n- `website_id` = the site's tenant ID (integer). Used for centralized-admin URL composition (e.g. `&newsite=<website_id>` on `ww2.managemydirectory.com/admin/...` links). Cache per session.\n\n- `profession` = SITE-LEVEL setting describing the archetype of member this directory lists (e.g. `\"Doctor\"`, `\"Personal Trainer\"`). **NOT** related to a member's `profession_id` (that's a foreign key into the per-member `list_professions` taxonomy).\n\n- `industry` = SITE-LEVEL setting describing the market/vertical the site serves (e.g. `\"Healthcare\"`, `\"Fitness\"`). Site metadata, not a member attribute.\n\n- `full_url` = the canonical site URL with `https://` and no trailing slash. Use this when composing public profile URLs (`<full_url>/<user.filename>`, `<full_url>/<seo_id filename>`).\n\n- `timezone` / `date_format` / `distance_format` / `website_currency` + the `currency_*` formatting bits = locale context for how to present data back to the user (dates, distances, money). Respect these when formatting.\n\n- `brand_images_relative` and `brand_images_absolute` = parallel objects with 8 keys each (`website_logo`, `website_mascot`, `website_background`, `favicon`, `default_profile_image`, `default_logo_image`, `verified_member_image`, `watermark`). Relative = path-only (e.g. `/images/logo.webp`); absolute = full https URL. Use absolute URLs when embedding in emails / external content; relative when embedding on the site itself.\n\n- `default_profile_image` on a member read signals \"no real photo\" — compare `image_main_file` to this URL to detect placeholder state.\n\n**Why agents should call this early:** the grounding it provides (site purpose, member archetype, locale) shapes every subsequent decision — what 'add a member' means, what categories are relevant, how to format dates and money, what the profile-placeholder image looks like, which brand assets to use in designs.\n\n_Auth: `X-Api-Key`. Rate limit: standard 100 req/60s. Cache for the session._", "tags": [ "Website Settings" ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } } } }, "/_synthetic/brand_kit": { "get": { "operationId": "getBrandKit", "summary": "Get the site's brand kit (colors + fonts) for design decisions", "description": "Return a compact, semantically-labeled brand kit for this BD site - colors (body / primary / dark / muted / success / warm / alert accents, card surface) + fonts (body + heading Google Fonts). **Call this ONCE at the start of any design-related task** (building a widget, WebPage, post template, email, hero banner - anything where colors or fonts are chosen) so the output visually matches the site's brand.\n\n**Handler is synthetic - makes 20 parallel internal calls to `/api/v2/website_design_settings/get?property=setting_name&property_value=custom_N&property_operator==` (one per brand-kit slot), then transforms the raw `custom_N` values into semantic labels. Uses BD's canonical mapping (same mapping BD's admin AI Companion applies). Parallel calls complete in ~1s wall-clock on typical sites; well under the 100 req/60s rate limit even on repeated invocations.**\n\n**No args.** Read-only. Safe to call anytime.\n\n**Response shape:**\n```\n{\n body: { background, text, font },\n primary: { color, text_on },\n dark: { color, text_on },\n muted: { color, text_on },\n success_accent: { color, text_on },\n warm_accent: { color, text_on },\n alert_accent: { color, text_on },\n card: { background, border, text, title },\n heading_font: \"<google font family>\",\n usage_guidance: { primary, dark, muted, success_accent, warm_accent, alert_accent, tint_rule, font_rule }\n}\n```\n\n**Usage guidance embedded in response** - agents should read it every call. Key rules:\n\n- **Primary** = brand color - main CTAs, dominant accents.\n\n- **Dark** = high-contrast sections or strong backgrounds.\n\n- **Muted** = subtle section backgrounds, dividers, badges, pills.\n\n- **Success / Warm / Alert accents** = specific semantic states (confirmations / attention / urgency). Use sparingly.\n\n- **Tint rule:** derive lighter/darker tints from palette colors for hover states, gradients, low-emphasis backgrounds. **Do NOT introduce new unrelated hues.**\n\n- **Font rule:** the site's `body.font` and `heading_font` Google Fonts are already globally loaded by BD. Do NOT redefine them in `content_css`. To switch to a different font, load it via a `<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/...\">` tag in `content_head` — never `@import` inside CSS (Outlook + some BD widget contexts strip or fail on `@import`).\n\n**When a slot is empty on the site**, the handler applies BD's documented fallback defaults (same defaults BD's admin AI Companion uses). Response is never missing keys - every field always has a value.\n\n_Auth: `X-Api-Key` header. Rate limit: 100 req/60s. Caches well on the agent side - the brand kit rarely changes within a session; call once, reuse._", "tags": [ "Design" ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "type": "object" } } } } } } }, "/api/v2/location_cities/get": { "get": { "operationId": "listCities", "summary": "List cities (location-based search & SEO slugs)", "description": "Paginated enumeration of cities enabled on this site for location-based member/post browsing and SEO page URL generation. Read-only source-of-truth for city slugs used in search-result URLs. Backed by BD's `location_cities` table.\n\n**Use when:** resolving a human city name (e.g. \"Beverly Hills\") to its `city_filename` slug (e.g. `beverly-hills`) before constructing a search-result URL for a static SEO page, or discovering which cities this site has seeded.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability. Useful filters: `city_ln` (full name, exact match), `city_filename` (slug, exact), `state_sn` (scope to one state), `country_sn` (scope to one country).\n\n**Returns:** `{ status: \"success\", message: [...rows] }` - each row has:\n\n- `locaiton_id` (integer PK, **BD schema typo - it is `locaiton_id`, NOT `location_id`**; pass the typo'd form when looking up a single record)\n\n- `city_ln` (full name)\n\n- `city_filename` (URL slug)\n\n- `state_sn` (2-letter state/province code; references `location_states.state_sn`)\n\n- `country_sn` (2-letter country code; references `list_countries.country_code`)\n\n**System-critical table - create & delete deliberately omitted from this MCP.** Cities are managed by BD automatically (a new city row is added when a member signs up from a new location). Creating cities via API risks slug collisions with auto-created rows, and deleting risks orphaning members whose `city` references the row. Use `updateCity` only for corrections (rename, fix typo in filename). For new cities, let the next member signup seed it.\n\n_Auth: `X-Api-Key` header. Rate limit: 100 req/60s (on 429, back off 60s). Errors: `{ \"status\": \"error\", \"message\": \"...\" }` - empty-result responses return `{status: \"error\", message: \"location_cities not found\", total: 0}` (same ambiguous pattern as other list endpoints)._", "tags": [ "Locations" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/property" }, { "$ref": "#/components/parameters/property_value" }, { "$ref": "#/components/parameters/property_operator" }, { "$ref": "#/components/parameters/order_column" }, { "$ref": "#/components/parameters/order_type" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } } } }, "/api/v2/location_cities/get/{locaiton_id}": { "get": { "operationId": "getCity", "summary": "Get a single city", "description": "Fetch one city row. Read-only. Note BD schema typo: PK is `locaiton_id` (sic), not `location_id`.\n\n**Required:** `locaiton_id` (path).\n\n**See also:** `listCities` (enumerate + filter).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }`.\n\n", "tags": [ "Locations" ], "parameters": [ { "name": "locaiton_id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "City primary key (BD schema typo: `locaiton_id`, NOT `location_id`)" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } } } }, "/api/v2/location_cities/update": { "put": { "operationId": "updateCity", "summary": "Update a city (corrections only)", "description": "Update an existing city row. Read-mostly - use sparingly. Fields omitted are untouched (PATCH semantics - only send what you want to change).\n\n**Use when:** correcting a typo in `city_ln` or `city_filename`, or reassigning a city's `state_sn` / `country_sn` if originally miscategorized. For a NEW city, DO NOT create via API - let the next member signup from that location auto-seed the row (BD handles this).\n\n**Required:** `locaiton_id` (BD schema typo - sic).\n\n**Warning on `city_filename` edits:** this is the URL slug used in every search-result page for that city. Changing it breaks all inbound links AND any static SEO pages (`seo_type=profile_search_results`) whose filename includes the old slug. If you must rename, create `Redirect` (301) records for each affected URL.\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }`.\n\n", "tags": [ "Locations" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "locaiton_id" ], "properties": { "locaiton_id": { "type": "integer", "description": "City PK (BD typo - sic)" }, "city_ln": { "type": "string" }, "city_filename": { "type": "string", "description": "URL slug. Changing this breaks inbound URLs - create redirects." }, "state_sn": { "type": "string" }, "country_sn": { "type": "string" }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } } } }, "/api/v2/location_states/get": { "get": { "operationId": "listStates", "summary": "List states / provinces / regions", "description": "Paginated enumeration of states/provinces/regions enabled on this site. The `location_states` table is country-agnostic - it holds US states, Canadian provinces, UK regions, and any other first-admin-level division for any country active on this site, distinguished by `country_sn`. Read-only source-of-truth for state slugs in search-result URLs.\n\n**Use when:** resolving a state/province name (\"California\", \"Ontario\") to its `state_filename` slug (`california`, `ontario`) before constructing a search-result URL.\n\n**Pagination + filter/sort:** standard. Useful filters: `state_ln` (full name), `state_sn` (2-letter code), `state_filename` (slug), `country_sn` (scope to one country - e.g. `US`, `CA`).\n\n**Returns:** rows with `location_id` (PK - NO typo here, unlike cities), `state_sn`, `state_ln`, `state_filename`, `country_sn`.\n\n**System-critical table - create & delete deliberately omitted.** States are seeded by BD as needed. Use `updateState` only for corrections.\n\n", "tags": [ "Locations" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/property" }, { "$ref": "#/components/parameters/property_value" }, { "$ref": "#/components/parameters/property_operator" }, { "$ref": "#/components/parameters/order_column" }, { "$ref": "#/components/parameters/order_type" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } } } }, "/api/v2/location_states/get/{location_id}": { "get": { "operationId": "getState", "summary": "Get a single state/province", "description": "Fetch one state row by `location_id`. Read-only.\n\n**Required:** `location_id` (path). Note: `location_states` PK is `location_id` (correctly spelled, unlike `location_cities.locaiton_id`).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }`.\n\n", "tags": [ "Locations" ], "parameters": [ { "name": "location_id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } } } }, "/api/v2/location_states/update": { "put": { "operationId": "updateState", "summary": "Update a state (corrections only)", "description": "Update a state row. Read-mostly - use for corrections. Fields omitted are untouched (PATCH semantics - only send what you want to change).\n\n**Required:** `location_id`.\n\n**Warning on `state_filename`:** it's the URL slug in every search page using this state. Rename -> broken URLs + orphaned SEO pages. Create redirects if you must.\n\n", "tags": [ "Locations" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "location_id" ], "properties": { "location_id": { "type": "integer" }, "state_sn": { "type": "string" }, "state_ln": { "type": "string" }, "state_filename": { "type": "string", "description": "URL slug. Rename breaks inbound URLs - create redirects." }, "country_sn": { "type": "string" }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } } } }, "/api/v2/list_countries/get": { "get": { "operationId": "listCountries", "summary": "List countries", "description": "Paginated enumeration of countries in the global reference table. Read-only reference.\n\n**Use when:** resolving a country name to its 2-letter `country_code` (ISO 3166-1 alpha-2) for cross-referencing in `location_states.country_sn` / `location_cities.country_sn`, or deriving a country URL slug.\n\n**Country URL slug derivation:** BD does NOT store a `country_filename` field. To construct the country segment of a search-result URL, derive it from `country_name` by lowercasing and replacing spaces with hyphens. Example: `country_name=\"United States\"` -> `country-slug=\"united-states\"`.\n\n**Pagination + filter/sort:** standard. Useful filters: `country_code`, `country_name`, `active`.\n\n**Returns:** rows with `country_id`, `country_code`, `country_name`, `active` (`1`=active, `0`=inactive).\n\n**System-critical table - create & delete deliberately omitted.** Countries are a global reference list. Use `updateCountry` only for corrections (e.g. toggling `active`).\n\n", "tags": [ "Locations" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/property" }, { "$ref": "#/components/parameters/property_value" }, { "$ref": "#/components/parameters/property_operator" }, { "$ref": "#/components/parameters/order_column" }, { "$ref": "#/components/parameters/order_type" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } } } }, "/api/v2/list_countries/get/{country_id}": { "get": { "operationId": "getCountry", "summary": "Get a single country", "description": "Fetch one country row. Read-only.\n\n**Required:** `country_id` (path).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }`.\n\n", "tags": [ "Locations" ], "parameters": [ { "name": "country_id", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } } } }, "/api/v2/list_countries/update": { "put": { "operationId": "updateCountry", "summary": "Update a country (corrections / active toggle)", "description": "Update a country record. Read-mostly - primary use is toggling `active` to enable/disable a country on the site. Fields omitted are untouched (PATCH semantics - only send what you want to change).\n\n**Required:** `country_id`.\n\n", "tags": [ "Locations" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "country_id" ], "properties": { "country_id": { "type": "integer" }, "country_code": { "type": "string", "description": "ISO 3166-1 alpha-2 (e.g. `US`, `CA`, `GB`)" }, "country_name": { "type": "string" }, "active": { "type": "integer", "enum": [ 0, 1 ] }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } } } }, "/api/v2/list_seo/get": { "get": { "operationId": "listWebPages", "summary": "List pages (list_seo)", "description": "Paginated enumeration of web pages (`list_seo` records). Read-only.\n\nReturns every static/SEO page on the site - homepage, about, contact, custom landing pages, templates, etc. Filter by `seo_type` to get pages of a specific type (e.g., only `content` pages).\n\n**Use when:** listing all site pages. Filter by `seo_type` to scope. For one page by `seo_id` use `getWebPage`.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination**.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators**.\n\n**Lean-by-default:** heavy asset fields stripped unless opted in. Structural + metadata fields always returned (`seo_id`, `seo_type`, `filename`, `title`, `meta_desc`, `meta_keywords`, `h1`, `h2`, `content_active`, `content_layout`, `form_name`, `menu_layout`, `enable_hero_section`, all `hero_*` fields, etc.). Flags:\n\n- `include_content=1` - return `content` (body HTML).\n- `include_code=1` - return `content_css`, `content_head`, `content_footer_html`.\n\nOn sites with heavy pages, a single row can be 10-30KB with code assets; `listWebPages limit=25` without stripping can exceed 500KB. Opt in only when you actually need the asset content (e.g. before `updateWebPage` edits to body/CSS/JS).\n\n**See also:** `getWebPage` (single by ID), `createWebPage`, `updateWebPage`.\n\n**Returns:** `{ status: \"success\", total, ..., message: [...records] }`.", "tags": [ "Pages" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "name": "include_content", "in": "query", "description": "Opt in to return the `content` (body HTML) field on each row. Default stripped.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, { "name": "include_code", "in": "query", "description": "Opt in to return `content_css`, `content_head`, `content_footer_html` on each row. Default stripped. Needed before `updateWebPage` edits to CSS/head/JS.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } } } }, "/api/v2/list_seo/get/{seo_id}": { "get": { "operationId": "getWebPage", "summary": "Get a single page", "tags": [ "Pages" ], "parameters": [ { "name": "seo_id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Page primary key" }, { "name": "include_content", "in": "query", "description": "Opt in to return the `content` (body HTML) field. Default stripped.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } }, { "name": "include_code", "in": "query", "description": "Opt in to return `content_css`, `content_head`, `content_footer_html`. Default stripped. Needed before `updateWebPage` edits to CSS/head/JS.", "schema": { "type": "integer", "default": 0, "enum": [ 0, 1 ] } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single web page by `seo_id`. Read-only.\n\n**Use when:** fetching one page's metadata (+ optionally its body/CSS/JS) before editing. Common for \"let me read the current About page before editing it\" workflows.\n\n**Required:** `seo_id` (path).\n\n**Lean-by-default:** same as `listWebPages` - heavy asset fields stripped unless opted in. Structural + metadata fields always returned. Flags:\n\n- `include_content=1` - return `content` (body HTML).\n- `include_code=1` - return `content_css`, `content_head`, `content_footer_html`.\n\nBefore `updateWebPage` edits to body, CSS, head, or footer HTML/JS, pass the matching flag so you have the current value to modify.\n\n**See also:** `listWebPages` (enumerate), `updateWebPage` (modify).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }`." } }, "/api/v2/list_seo/create": { "post": { "operationId": "createWebPage", "summary": "Create a page", "description": "Create a `list_seo` page record. Writes live data.\n\n**Cache refresh is automatic.** Response includes `auto_cache_refreshed: true` after successful writes; no manual `refreshSiteCache` call needed. If `auto_cache_refreshed: false`, check `auto_cache_refresh_error` and retry `refreshSiteCache` once.\n\n**Required fields:** `seo_type`. `filename` is required for every `seo_type` EXCEPT `data_category` (the WRAPPER generates a 10-char lowercase alphanumeric placeholder slug for that type and auto-creates a 301 redirect to the canonical post-type URL; the public URL routes via the post type's `data_filename`, not `list_seo.filename`). When `seo_type=data_category`, `linked_post_type` is also required (auto-validated at runtime).\n\n**Filename uniqueness — enforced by the wrapper, no exceptions.** BD does NOT enforce unique `filename` server-side, but duplicates break the platform (two pages at the same URL render non-deterministically). The wrapper auto-pre-checks `listWebPages` for an existing slug before forwarding the create. If a row exists, the create is rejected with the existing `seo_id` so the agent can `updateWebPage` instead, or pick a unique slug. There is no agent-facing bypass; for `seo_type=data_category` the wrapper generates the slug itself (10-char lowercase alphanumeric — statistically unique across `36^10`, no pre-check needed).\n\n**Thin-content warning:** if no `title`, `h1`, `meta_desc`, or `content` is set on a `seo_type=content` create, a `_thin_content_warning` field is attached to the response. The page is still created and is publicly live — Google may index it as thin content. Fix: provide at least one of those fields on the create call, or `updateWebPage` immediately after, or `deleteWebPage` if the create was premature.\n\n**Asset field routing (mandatory - Froala strips mismatched content silently):**\n\n- `content` - body HTML. No `<style>`/`<script>` tags. Supports `[widget=Name]` shortcodes + `%%%token%%%`.\n\n- `content_css` - raw CSS rules. NO `<style>` wrapper. Scope to a unique page class; never target `.container`/`.froala-table`/`.image-placeholder` (reserved). Do NOT use `@import` (causes FOUC/CLS - use `content_head` `<link>` tag instead).\n\n- `content_footer_html` - JavaScript, pixels, analytics embeds (`<script>` tags OK here). IIFE-wrap + scope.\n\n- `content_head` - head-only deps (`<link>`, `<meta>`, JSON-LD, external stylesheets, fonts).\n\n- `content_footer` - MISLEADING NAME. NOT footer HTML. Page-access gate enum: `\"\"` (public), `\"members_only\"`, `\"digital_products\"`.\n\n- Hero banner -> `enable_hero_section` + `hero_*` + `h1_*`/`h2_*` fields.\n\nAll asset fields accept raw content verbatim. No CDATA, no `<parameter>`/`<invoke>`/`<function_calls>` scaffolding, no entity-escaped HTML — forbidden anywhere in the value, not just as wrappers. Server strips these as a safety net; do not rely on it.\n\n**SVG/canvas prohibited in `content`** - Froala strips them. Charts/diagrams go in a custom Widget, embedded via `[widget=Name]` shortcode.\n\n**`seo_type` values:** `home` (system-seeded; cannot CREATE homepage, only `updateWebPage`), `content` (generic static page), `profile_search_results` (member search override — apply **Rule: Member search SEO pages**), `data_category` (post search), `custom_widget_page`, `password_retrieval_page`, `unsubscribed`.\n\n**Hero section - when `enable_hero_section` = `1` or `2`, apply **Rule: Hero readability bundle** (atomic — all listed values must be sent together).** Notes:\n\n- All color fields RGB ONLY (`rgb(0, 0, 0)`) - hex not accepted.\n\n- Hero `h1_*`/`h2_*` fields style ONLY the hero banner; H1/H2 TEXT comes from the record's top-level `h1`/`h2` fields.\n\n- Hero image: content-relevant Pexels stock photo (free license, no attribution). See **Rule: Image URLs** (imported field — bare URL, no query string). Never `picsum.photos`/`lorempixel`/`placekitten`.\n\n- Hero gap-fix CSS (`seo_type=content` ONLY): add `.hero_section_container + div.clearfix-lg {display:none}` to `content_css` to close BD's 40px clearfix gap. **Never add this rule on any other `seo_type`** - on `profile_search_results` / `data_category` the clearfix provides needed spacing before live search-results; hiding it causes results to butt-join the hero.\n\n- Hero is cache-gated — but `createWebPage`/`updateWebPage` auto-refresh handles it; no separate call needed.\n\n- **Homepage hero is BENIGN**: `seo_id=1` stores hero fields but the homepage template does NOT render them. Skip hero fields on homepage unless user explicitly asks.\n\n**`profile_search_results` SEO pages - thin-content remedy workflow:**\n\nUsed to override BD's auto-generated dynamic search URLs (e.g. `california/beverly-hills/plumbers`) with static custom SEO copy. Creating a `list_seo` row with a matching `filename` takes over the public URL.\n\n**CRITICAL - `filename` MUST be a real slug BD's dynamic router recognizes.** Arbitrary slugs render HTTP 404 publicly even when the record is created successfully. See **Rule: Member search SEO pages** for the canonical slug hierarchy (`country/state/city/top/sub`, strict order, any subset valid) and the live-lookup endpoints for each segment. Wrapper validates segments at runtime — country slug is derived from `country_name` (lowercase + spaces→hyphens). For arbitrary-URL static pages use `seo_type=content`.\n\n**Workflow for \"add SEO to [category] in [location]\":**\n1. Resolve each human name to its slug via the relevant `list*` endpoint (exact-match `=`).\n2. For ambiguous inputs (e.g. \"Beverly Hills plumbers\" - could be `beverly-hills/plumbers` or `california/beverly-hills/plumbers`), ask user which variant.\n3. Pre-check: `listWebPages property=filename property_value=<slug> property_operator==`. Exists -> `updateWebPage`. Missing -> `createWebPage` with the required defaults listed in step 4.\n4. **Required defaults on create and every update** (unless user overrides):\n - `seo_type=profile_search_results`\n - `custom_html_placement=4` (Below Body Content - safest for boilerplate intro without disrupting live results)\n - `form_name=\"Member Search Result\"` (sidebar - Master Default; do NOT use `Member Profile Page`, that's for profile pages)\n - `menu_layout=3` (Left Slim sidebar position)\n - `enable_hero_section=1` + content-relevant Pexels `hero_image` + the readability safe-defaults from **Rule: Hero readability bundle** (atomic — all listed values must be sent together). Most end-users don't know to ask for a hero; thin-SEO pages underperform without one. User can opt out with `enable_hero_section=0`. (Cache flush is automatic post-write.)\n5. **Auto-generate SEO meta for the specific combo - don't leave blank:**\n - `title` - 50-60 chars ideal, <=70 max. Pattern: `\"[Category] in [City], [State] | [Site Name]\"`.\n - `meta_desc` - 150-160 chars ideal, <=170 max. 1-2 sentence pitch with location + CTA.\n - `meta_keywords` - ~200 chars, comma-separated (no spaces).\n - `facebook_title` - 55-60 chars, differ from `title` (more conversational).\n - `facebook_desc` - 110-125 chars, punchier than `meta_desc`.\n - Do NOT auto-set `facebook_image` (needs uploaded asset).\n6. **H1/H2 double-render trap:** if hero enabled AND `content` contains `<h1>`/`<h2>`, both render. Either set `h1`/`h2` fields and omit from `content`, or put in `content` and leave fields blank. Never both.\n7. **No max-width wrappers in `content` or `content_css`** on `profile_search_results` pages. BD's layout already provides the outer container; adding `max-width: 960px; margin: auto` double-constrains to a narrow strip. Let content flow at natural container width.\n8. `custom_html_placement` is only meaningful on `profile_search_results` (and `data_category`). Ignored on `content` pages.\n\n**SEO content for categories:** route to `createWebPage seo_type=profile_search_results` (NOT `updateTopCategory.desc` / `updateSubCategory.desc` - those are internal labels, not rendered).\n\n**`list_seo` EAV fields — auto-routed by the wrapper, no special handling.** Pass any field on `createWebPage` / `updateWebPage` directly; if it's an EAV-stored field (e.g. `hero_*`, `h1_*`, `h2_*`, `linked_post_category`, `disable_*`), the wrapper routes the write through `users_meta` automatically. Response includes an `eav_results` array confirming which EAV fields were written. Reads merge automatically via `getWebPage`/`listWebPages`. On `deleteWebPage`: BD does NOT cascade — run orphan cleanup per **Rule: users_meta orphans** (`listUserMeta` filtered by `database=list_seo`+`database_id=<deleted seo_id>`, then `deleteUserMeta` each match).\n\n**See also:** `listWebPages`, `updateWebPage`, `createRedirect` (preserve SEO on slug changes).\n\n**Returns:** `{ status: \"success\", message: {...createdRecord}, auto_cache_refreshed: true|false, auto_cache_refresh_error?: \"...\", _admin_edit_url: \"...\" }` including `seo_id`. `auto_cache_refreshed` reports whether the automatic cache flush succeeded; if `false`, `auto_cache_refresh_error` explains why and the agent should retry `refreshSiteCache` manually once. `_admin_edit_url` is a centralized-admin deep-link to the WebPage editor for this `seo_id` — surface it to the user so they can jump straight to the admin edit screen for the page just created.", "tags": [ "Pages" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "seo_type" ], "properties": { "seo_type": { "type": "string", "enum": [ "content", "data_category", "profile_search_results", "custom_widget_page", "password_retrieval_page", "unsubscribed" ], "description": "Page type identifier. User-selectable values:\n\n- `content` = Single Web Page (USE for custom/landing/static/about/contact - the default)\n\n- `data_category` = Post Search Results\n\n- `profile_search_results` = Member Search Results\n\n- `custom_widget_page` = Custom Widget as Web Page\n\n- `password_retrieval_page` = Password Retrieval Page\n\n- `unsubscribed` = Unsubscribed Page\n\nFor \"landing page\", \"static page\", \"about page\", \"contact page\", any generic custom page -> always `content`.\n\nBD has additional internal values (`home`, `profile`, `payment`, etc.) that are system-seeded - do NOT create via API." }, "filename": { "type": "string", "description": "URL slug (e.g. home, about-us). Must be unique across web pages, top categories, sub categories, plan public URLs, and member profile slugs — wrapper auto-rejects collisions. Either pick a unique slug or `updateWebPage` the existing record. **OPTIONAL on `seo_type=data_category`** — the WRAPPER generates a 10-char lowercase alphanumeric placeholder slug if omitted (or rewrites a non-conforming agent-supplied value), and auto-creates a 301 redirect from that slug to the canonical post-type URL. The public URL routes via the post type's `data_filename` + category, not `list_seo.filename`. REQUIRED on every other seo_type." }, "nickname": { "type": "string", "description": "Human-readable label shown in admin panel" }, "title": { "type": "string", "description": "**Page Meta Title** - Supports template tokens: `%%%website_name%%%`, `%industry%`, `%profession%`, etc. ~30-60 chars recommended. HTML title tag - supports template tokens like %%%website_name%%%" }, "meta_keywords": { "type": "string", "description": "**Meta Keywords** - Supports template tokens (comma-separated). Meta keywords - supports template tokens" }, "meta_desc": { "type": "string", "description": "**Meta Description** - Supports template tokens. ~150-160 chars recommended for search snippets. Meta description - supports template tokens" }, "seo_text": { "type": "string", "description": "**Wildcard URL Rewrite.** `1` = any URL within this directory routes to this web page (catch-all behavior). Misnamed field - NOT SEO copy; SEO copy goes in `content` + meta fields." }, "h1": { "type": "string", "description": "**H1 Heading** - Supports template tokens. Rendered as the page's main heading. H1 heading - supports template tokens" }, "h2": { "type": "string", "description": "**H2 Heading** - Supports template tokens. H2 heading - supports template tokens" }, "breadcrumb": { "type": "string", "description": "**OMIT** — BD auto-generates the breadcrumb trail. Never set this yourself; a manual value overrides BD's generated trail and breaks the page." }, "content": { "type": "string", "description": "Main page body - Froala rich-text editor. **Shortcodes:** `[form=<form_name>]` embeds a BD form; `[widget=<widget_name>]` embeds a widget; `%%%template_tokens%%%` for site vars. **HTML only** - Froala strips `<style>`, `<script>`, `<form>`, `<input>`, `<select>`, `<textarea>`, `contenteditable=`, AND inline `style=\"...\"` attributes on save.\n\nRoute all non-body assets to their dedicated fields: CSS -> `content_css` (target classes, not inline styles), JS -> `content_footer_html`, head deps (`<link>`, fonts, `<meta>`, JSON-LD) -> `content_head`. SVG/canvas also stripped; for charts/diagrams use a custom Widget embedded via `[widget=Name]` shortcode." }, "content_css": { "type": "string", "description": "Custom CSS for this page. Paste raw CSS rules directly - NO `<style>` wrapper. Renders in page `<head>` at load. Scope every selector to a unique page class/ID (e.g. `.my-about-page h2 { ... }`) - bare `body`/`h1`/`p` affect the whole site. Never target reserved BD classes: `.container`, `.froala-table`, `.image-placeholder`. Never `@import` (FOUC/CLS) - load external stylesheets/fonts via `<link>` tag in `content_head`.\n\n**Admin Froala editor gotcha** - editor applies `content_css` but does NOT run `content_footer_html` JS. Hide-by-default CSS (scroll reveals, tab panels, accordion collapsed, modal hidden, slider non-active slides) will permanently hide content in the editor. Gate such rules behind a `.js-ready` class that `content_footer_html` JS adds on load: `.my-page.js-ready .reveal { opacity:0 }` NOT `.my-page .reveal { opacity:0 }`. The paired JS rule lives in the `content_footer_html` field." }, "content_footer_html": { "type": "string", "description": "Page-scoped JavaScript + script embeds - rendered before `</body>`. `<script>` tags accepted here (unlike `content`). jQuery loaded globally. Wrap JS in an IIFE `(function($){ ... })(jQuery);` and scope selectors to a unique page class. Also for third-party script embeds (analytics pixels, chat widgets, schema markup). NOT for extra body HTML - `content` is the body field.\n\n**If `content_css` uses a `.js-ready` gate for hide-by-default effects** (scroll reveals, tab panels, accordion collapse, modal hidden, slider non-active), JS MUST add that class to the page wrapper as the FIRST line (before any other init code): `document.querySelector('.my-page')?.classList.add('js-ready');`. The admin Froala editor applies CSS but does NOT run this field's JS, so without the gate, hide-rules make content permanently invisible in the editor." }, "content_head": { "type": "string", "description": "Page-scoped `<head>` dependencies - rendered inside `<head>`. Use for: `<link>` tags (external stylesheets, preconnect hints, canonical overrides, Google Fonts), `<meta>` tags beyond standard SEO fields, verification tags, JSON-LD structured data (`<script type=\"application/ld+json\">`), head-required third-party scripts (rare - prefer `content_footer_html` for most JS)." }, "content_footer": { "type": "string", "enum": [ "", "members_only", "digital_products" ], "description": "**MISLEADING NAME - NOT page footer HTML.** Misnamed relic column; BD repurposed as the **page-access gate**:\n\n- `\"\"` (default) = Public For Everyone\n\n- `\"members_only\"` = Logged-in members only (non-members hit login/signup wall)\n\n- `\"digital_products\"` = Only buyers of digital-product items\n\nFiner rules (which members, which plans) live in other fields. Do NOT put HTML here. Page body -> `content`; scripts -> `content_footer_html`. No dedicated \"below-body HTML\" field - put below-body markup inside `content` itself." }, "content_menu": { "type": "string", "description": "Menu section this page belongs to" }, "content_order": { "type": "integer", "description": "Sort order within menu/section" }, "content_group": { "type": "string", "description": "Admin-panel grouping label" }, "content_layout": { "type": "integer", "enum": [ 1 ], "description": "**Full Screen Page Width override.** OMIT for normal pages (BD's default container width). Set to `1` for full-bleed pages — individual sections in `content` can then break edge-to-edge (background bands, hero strips, viewport-wide images).\n\n**For full-bleed sections, set `content_layout=1` FIRST.** Do NOT fake full-bleed with negative-margin/9999px-padding tricks in `content_css` — breaks horizontal scroll, fights `overflow: hidden` parents, prevents future layout changes. Anti-pattern.\n\nPattern with `content_layout=1`: scoped CSS in `content_css` gives each section its own edge-to-edge background; inner `<div class=\"container\">` (or page-scoped max-width wrapper) keeps readable copy centered." }, "content_sidebar": { "type": "string", "description": "Sidebar configuration or widget shortcode" }, "menu_layout": { "type": "integer", "enum": [ 1, 2, 3, 4 ], "description": "Sidebar position + width (integer). Only effective when the page has a sidebar set via `form_name` - ignored without sidebar. NOT a navigation menu layout despite the field name.\n\n- `1` = Left Wide (BD default when unspecified)\n\n- `2` = Right Wide\n\n- `3` = Left Slim\n\n- `4` = Right Slim\n\nOrdering is NOT sequential by side - left positions are `1` and `3`, right are `2` and `4`.\n\n**Default on `profile_search_results` pages:** `3` (Left Slim). On `content` pages, omit unless user specifies (BD defaults to `1`)." }, "show_form": { "type": "integer", "enum": [ 0, 1 ], "description": "**Apply NoIndex,NoFollow.** `1` = adds `<meta name=\"robots\" content=\"noindex,nofollow\">` to the page. Auto-applied to protected pages. **NOT a form-render toggle** despite the field name — BD repurposed this column. To render a form in the body, use `[form=<form_name>]` inside `content`." }, "form_name": { "type": "string", "description": "**SIDEBAR name** for this page - BD's field is misnamed; controls sidebar slot, NOT a contact form. NOT for rendering forms on this page — to embed a form in the body, use `[form=<form_name>]` inside `content`. Pass exact sidebar `name` string. `\"\"` = no sidebar. Valid values: a Master Default Sidebar OR a custom sidebar `name` from `listSidebars`. See **Rule: Sidebars** for the canonical Master Default list and selection workflow.\n\n`menu_layout` controls position when `form_name` is set. **Default on `profile_search_results` pages:** `Member Search Result` (NOT `Member Profile Page` - that's for profile/detail pages, not search results)." }, "hide_header_links": { "type": "integer", "enum": [ 0, 1 ], "description": "**Hide Main Menu** - 1 = hides the main navigation menu on this page." }, "hide_header": { "type": "integer", "enum": [ 0, 1 ], "description": "**Hide Header** - 1 = hides the full site header on this page." }, "hide_footer": { "type": "integer", "enum": [ 0, 1 ], "description": "**Hide Footer** - 1 = hides the site footer on this page." }, "hide_top_right": { "type": "integer", "enum": [ 0, 1 ], "description": "**Hide Top Header Menu** - 1 = hides the top-right nav cluster (account/login links)." }, "facebook_title": { "type": "string", "description": "**Social Media Title (Open Graph)** - Title shown when the page is shared on Facebook/LinkedIn/etc. Open Graph title for social sharing" }, "facebook_desc": { "type": "string", "description": "**Social Media Description (Open Graph)** - Description shown on social shares. Open Graph description" }, "facebook_image": { "type": "string", "description": "**Social Media Shared Image** - URL/filename of the OG image. BD recommends at least 200×200px. Open Graph image URL" }, "org_template": { "type": "integer", "description": "**OMIT** — internal layout reference. No public lookup endpoint; setting an arbitrary value can render the page against a nonexistent layout." }, "allowed_products": { "type": "string", "description": "Comma-separated plan/product IDs (empty = all plans)" }, "custom_html_placement": { "type": "string", "enum": [ "0", "1", "2", "3", "4" ], "description": "Render position of `content` HTML relative to dynamic search results. Only meaningful on `seo_type=profile_search_results` (and `data_category`); ignored on `content` pages.\n\n- `0` = Inside Tab (content + members in separate nav tabs)\n\n- `1` = Above Member Results (within results container, sidebar-width)\n\n- `2` = Below Member Results (within results container)\n\n- `3` = Above Body Content (full page width, spans sidebar+results)\n\n- `4` = Below Body Content (full page width, below sidebar+results) <- **recommended default for AI-generated SEO pages**\n\nFor boilerplate SEO intro/FAQ/local copy bolstering thin pages, `4` renders full-width below the live results without disrupting member-facing UX." }, "enable_hero_section": { "type": "string", "enum": [ "0", "1", "2" ], "description": "Hero banner master switch:\n\n- `0` = disabled (all other `hero_*`/`h1_font_*`/`h2_font_*` ignored at render; stored values preserved for later toggle-back)\n\n- `1` = enabled all devices\n\n- `2` = enabled desktop, hidden mobile\n\n**On hero off→on transition (`0`/unset → `1`/`2`), wrapper auto-fills the hero readability bundle** — `hero_top_padding=100`, `hero_bottom_padding=100`, `hero_column_width=5`, `hero_content_overlay_color=rgb(0, 0, 0)`, `hero_content_overlay_opacity=0.5`, `hero_content_font_color=rgb(255, 255, 255)`, `hero_content_font_size=18`, `h1_font_color=rgb(255, 255, 255)`, `h2_font_color=rgb(255, 255, 255)` — for any of those 9 fields you OMITTED. BD's per-field defaults render an unreadable hero (10px content text on a 0.4-opacity overlay, default top/bottom padding 70/60 — visually too cramped for most banner imagery); the bundle is the canonical readable recipe. User-supplied values pass through untouched. Filled fields are echoed in `_hero_bundle_autofilled`. **`hero_image` is required** on transition — wrapper rejects if missing (no safe default; walk the image-sourcing ladder). On no-transition updates (hero already on), no auto-fill fires.\n\n**Homepage benign** - `seo_type=home` ignores hero fields entirely regardless of value. BD stores but never renders on homepage; skip all `hero_*` fields on homepage updates." }, "hero_hide_banner_ad": { "type": "string", "enum": [ "0", "1" ], "description": "When `1`, suppresses the site-wide \"Below Header Banner Ad\" on THIS page only (useful when the hero visually replaces that slot). `0` (default) keeps the banner ad in its normal position." }, "hero_image": { "type": "string", "description": "Hero background image. Accepts BD-hosted relative path (`/images/bg202.webp`) OR external URL (`https://cdn.example.com/banner.jpg`) — external URLs render hotlinked on WebPages, no `auto_image_import` needed. **LANDSCAPE only — never portrait/vertical; source via `https://www.pexels.com/search/<term>/?orientation=landscape`; bare URL, no `?query`, must end in `.jpg`/`.jpeg`/`.png`/`.webp` — see **Rule: Image URLs**.** Query strings get truncated/mangled in BD's form-urlencoded parsing and the stored URL becomes invalid. Recommended dimensions: 1800 × 600 px." }, "hero_background_image_size": { "type": "string", "enum": [ "mobile-ready", "standard" ], "description": "Controls how the hero background image scales/crops across devices. `mobile-ready` (recommended) = responsive behavior tuned for mobile, `standard` = fixed-ratio behavior." }, "hero_content_overlay_color": { "type": "string", "description": "Semi-transparent color layer between hero background image and text, for legibility over busy images. **RGB format ONLY** - `rgb(0, 0, 0)` or `rgb(255, 255, 255)`. Hex (`#000000`) NOT accepted. Combine with `hero_content_overlay_opacity` to control strength. Wrapper auto-fills `rgb(0, 0, 0)` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "hero_content_overlay_opacity": { "type": "string", "enum": [ "0.0", "0.1", "0.2", "0.3", "0.4", "0.5", "0.6", "0.7", "0.8", "0.9", "1" ], "description": "Opacity of `hero_content_overlay_color` layer, 0.1 increments from `0.0` (transparent) to `1` (opaque). Admin UI labels 0-10. BD field default `0.4` is too transparent — wrapper auto-fills `0.5` on hero off→on transition (part of **Rule: Hero readability bundle**). EAV-routed by the wrapper — pass on `updateWebPage` directly, no manual `updateUserMeta` needed." }, "hero_top_padding": { "type": "string", "description": "Top padding inside the hero banner, in pixels. Accepts multiples of 10 from `0` to `200`. BD field default `70` — wrapper auto-fills `100` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "hero_bottom_padding": { "type": "string", "description": "Bottom padding inside the hero banner, in pixels. Accepts multiples of 10 from `0` to `200`. BD field default `60` — wrapper auto-fills `100` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "hero_column_width": { "type": "string", "enum": [ "3", "4", "5", "6", "7", "8", "9", "10", "11", "12" ], "description": "Hero text-content column width as Bootstrap 12-col span. `3`=25%, `4`=30%, `5`=40%, `6`=50%, `7`=60%, `8`=70%, `9`=75%, `10`=80%, `11`=90%, `12`=100%. Narrower = more side padding around the text block. BD field default `8` — wrapper auto-fills `5` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "hero_alignment": { "type": "string", "enum": [ "left", "center", "right" ], "description": "Horizontal alignment of the hero title/subtitle/content text block within its column. Default `center`." }, "h1_font_color": { "type": "string", "description": "Main title (H1) font color in the hero. RGB format ONLY - e.g. `rgb(255, 255, 255)`. The H1 text itself comes from the page's `h1` field - these `h1_*` fields control ONLY the hero's H1 styling. Wrapper auto-fills `rgb(255, 255, 255)` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "h1_font_size": { "type": "string", "description": "Main title (H1) font size in pixels. Accepts integer values from `30` to `80`. OMIT to inherit BD's per-`seo_type` default." }, "h1_font_weight": { "type": "string", "enum": [ "300", "400", "600", "800" ], "description": "Main title (H1) font weight. `300`=Light, `400`=Normal (default), `600`=Bold, `800`=Extra Bold." }, "h2_font_color": { "type": "string", "description": "Sub-title (H2) font color in the hero. RGB format ONLY - e.g. `rgb(255, 255, 255)`. The H2 text itself comes from the page's `h2` field. Wrapper auto-fills `rgb(255, 255, 255)` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "h2_font_size": { "type": "string", "description": "Sub-title (H2) font size in pixels. Accepts integer values from `20` to `60`. OMIT to inherit BD's per-`seo_type` default." }, "h2_font_weight": { "type": "string", "enum": [ "300", "400", "600", "800" ], "description": "Sub-title (H2) font weight. `300`=Light, `400`=Normal, `600`=Bold (default), `800`=Extra Bold." }, "hero_content_font_color": { "type": "string", "description": "Font color for the additional hero content block rendered below H1/H2 (the `hero_section_content` field). RGB format ONLY - e.g. `rgb(0, 0, 0)`. Wrapper auto-fills `rgb(255, 255, 255)` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "hero_content_font_size": { "type": "string", "description": "Font size in pixels for the additional hero content block (`hero_section_content`). Accepts integer values from `10` to `30`. BD field default `10` is too small for hero paragraph copy — wrapper auto-fills `18` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "hero_section_content": { "type": "string", "description": "Additional text / HTML / widget shortcode rendered BELOW H1 and H2 in the hero section. Supports `[widget=Name]` shortcodes. EAV-routed by the wrapper — pass on `createWebPage` / `updateWebPage` directly, no manual `updateUserMeta` needed." }, "hero_link_url": { "type": "string", "description": "Hero call-to-action (CTA) button link URL. If empty, no CTA button is rendered. For internal links, a relative path is fine (e.g. `/signup`); for external, full URL with `http://` or `https://`." }, "hero_link_text": { "type": "string", "description": "Hero CTA button label. Required (non-empty) for the button to render - `hero_link_url` alone without text will not produce a button." }, "hero_link_target_blank": { "type": "string", "enum": [ "0", "1" ], "description": "When `1`, opens the CTA link in a new tab (`target=\"_blank\"`). `0` (default) opens in the same tab." }, "hero_link_size": { "type": "string", "enum": [ "", "btn-lg", "btn-xl" ], "description": "CTA button size. MUST be exactly one of: `\"\"` (empty = Normal), `btn-lg` (Large), `btn-xl` (Extra Large). Any other value (e.g. a font-size number like `16`) is stored verbatim and rendered as a broken class — BD does not validate server-side." }, "hero_link_color": { "type": "string", "enum": [ "primary", "info", "success", "warning", "danger", "default", "secondary" ], "description": "CTA button color variant — attention level, not literal color. MUST be exactly one of: `primary`, `info`, `success`, `warning`, `danger`, `default`, `secondary`. Any other value (e.g. a hex `#ffffff`) is stored verbatim and rendered as a broken class like `btn-#ffffff` — BD does not validate server-side.\n\nChoose by attention level needed: `primary` (main CTA), `danger` (urgent/can't-miss), `warning` (attention), `success` (positive action), `info` (neutral-blue), `secondary` (theme secondary), `default` (low-emphasis gray). Actual rendered color comes from the site's theme palette." }, "linked_post_type": { "type": "string", "description": "Post type's `data_id` (from `listPostTypes`). REQUIRED when `seo_type=data_category`; ignored on other seo_types. See **Rule: Resource disambiguation** when the user names a post type by description rather than `data_id`." }, "linked_post_category": { "type": "string", "description": "Either the literal `post_main_page` (pins to the post type's main search-results page) OR an exact category name from the linked post type's `feature_categories` (e.g. `\"Category 1\"`, case-sensitive). Optional on `seo_type=data_category` — wrapper auto-defaults to `post_main_page` when omitted on a fresh data_category create or content→data_category switch. Ignored on other seo_types. Wrapper enforces pair-uniqueness on `(linked_post_type, linked_post_category)`." }, "disable_css_stylesheets": { "type": "integer", "enum": [ 0, 1 ], "description": "Disable BD's site stylesheets on this page (frontend only). `1` = page renders without BD's global CSS (use when embedding a fully self-styled custom page or iframe target). `0` (default) = normal BD styling. EAV-stored — agent passes directly; wrapper handles routing on update." }, "private_page_select": { "type": "string", "description": "Access control setting" }, "page_render_widget": { "type": "string", "description": "**OMIT** — internal widget reference for `seo_type=custom_widget_page` only. No public widget-ID lookup; setting on other page types breaks rendering." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } } } }, "/api/v2/list_seo/update": { "put": { "operationId": "updateWebPage", "summary": "Update a page", "tags": [ "Pages" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "seo_id" ], "properties": { "seo_id": { "type": "integer", "description": "Page primary key (required to identify record)" }, "seo_type": { "type": "string", "enum": [ "content", "data_category", "profile_search_results", "custom_widget_page", "password_retrieval_page", "unsubscribed", "home" ], "description": "Page type identifier. On update, safest to OMIT this field unless intentionally changing it (most seo_type changes are destructive). Values:\n content = Single Web Page (generic static/landing page)\n data_category = Post Search Results\n profile_search_results = Member Search Results\n custom_widget_page = Custom Widget as Web Page\n password_retrieval_page = Password Retrieval Page\n unsubscribed = Unsubscribed Page\n home = Homepage (system-seeded; included here so an update round-trip of the existing homepage record passes the enum check)" }, "filename": { "type": "string", "description": "URL slug (e.g. home, about-us). Renaming to a slug already used by any web page, top category, sub category, plan public URL, or member profile slug is auto-rejected — pick a different slug or rename the conflict first." }, "nickname": { "type": "string", "description": "Human-readable label shown in admin panel" }, "title": { "type": "string", "description": "**Page Meta Title** - Supports template tokens: `%%%website_name%%%`, `%industry%`, `%profession%`, etc. ~30-60 chars recommended. HTML title tag - supports template tokens like %%%website_name%%%" }, "meta_keywords": { "type": "string", "description": "**Meta Keywords** - Supports template tokens (comma-separated). Meta keywords - supports template tokens" }, "meta_desc": { "type": "string", "description": "**Meta Description** - Supports template tokens. ~150-160 chars recommended for search snippets. Meta description - supports template tokens" }, "seo_text": { "type": "string", "description": "**Wildcard URL Rewrite.** `1` = any URL within this directory routes to this web page (catch-all behavior). Misnamed field - NOT SEO copy; SEO copy goes in `content` + meta fields." }, "h1": { "type": "string", "description": "**H1 Heading** - Supports template tokens. Rendered as the page's main heading. H1 heading - supports template tokens" }, "h2": { "type": "string", "description": "**H2 Heading** - Supports template tokens. H2 heading - supports template tokens" }, "breadcrumb": { "type": "string", "description": "**OMIT** — BD auto-generates the breadcrumb trail. Never set this yourself; a manual value overrides BD's generated trail and breaks the page." }, "content": { "type": "string", "description": "Main page body - Froala rich-text editor. **Shortcodes:** `[form=<form_name>]` embeds a BD form; `[widget=<widget_name>]` embeds a widget; `%%%template_tokens%%%` for site vars. **HTML only** - Froala strips `<style>`, `<script>`, `<form>`, `<input>`, `<select>`, `<textarea>`, `contenteditable=`, AND inline `style=\"...\"` attributes on save.\n\nRoute all non-body assets to their dedicated fields: CSS -> `content_css` (target classes, not inline styles), JS -> `content_footer_html`, head deps (`<link>`, fonts, `<meta>`, JSON-LD) -> `content_head`. SVG/canvas also stripped; for charts/diagrams use a custom Widget embedded via `[widget=Name]` shortcode." }, "content_css": { "type": "string", "description": "Custom CSS for this page. Paste raw CSS rules directly - NO `<style>` wrapper. Renders in page `<head>` at load. Scope every selector to a unique page class/ID (e.g. `.my-about-page h2 { ... }`) - bare `body`/`h1`/`p` affect the whole site. Never target reserved BD classes: `.container`, `.froala-table`, `.image-placeholder`. Never `@import` (FOUC/CLS) - load external stylesheets/fonts via `<link>` tag in `content_head`.\n\n**Admin Froala editor gotcha** - editor applies `content_css` but does NOT run `content_footer_html` JS. Hide-by-default CSS (scroll reveals, tab panels, accordion collapsed, modal hidden, slider non-active slides) will permanently hide content in the editor. Gate such rules behind a `.js-ready` class that `content_footer_html` JS adds on load: `.my-page.js-ready .reveal { opacity:0 }` NOT `.my-page .reveal { opacity:0 }`. The paired JS rule lives in the `content_footer_html` field." }, "content_footer_html": { "type": "string", "description": "Page-scoped JavaScript + script embeds - rendered before `</body>`. `<script>` tags accepted here (unlike `content`). jQuery loaded globally. Wrap JS in an IIFE `(function($){ ... })(jQuery);` and scope selectors to a unique page class. Also for third-party script embeds (analytics pixels, chat widgets, schema markup). NOT for extra body HTML - `content` is the body field.\n\n**If `content_css` uses a `.js-ready` gate for hide-by-default effects** (scroll reveals, tab panels, accordion collapse, modal hidden, slider non-active), JS MUST add that class to the page wrapper as the FIRST line (before any other init code): `document.querySelector('.my-page')?.classList.add('js-ready');`. The admin Froala editor applies CSS but does NOT run this field's JS, so without the gate, hide-rules make content permanently invisible in the editor." }, "content_head": { "type": "string", "description": "Page-scoped `<head>` dependencies - rendered inside `<head>`. Use for: `<link>` tags (external stylesheets, preconnect hints, canonical overrides, Google Fonts), `<meta>` tags beyond standard SEO fields, verification tags, JSON-LD structured data (`<script type=\"application/ld+json\">`), head-required third-party scripts (rare - prefer `content_footer_html` for most JS)." }, "content_footer": { "type": "string", "enum": [ "", "members_only", "digital_products" ], "description": "**MISLEADING NAME - NOT page footer HTML.** Misnamed relic column; BD repurposed as the **page-access gate**:\n\n- `\"\"` (default) = Public For Everyone\n\n- `\"members_only\"` = Logged-in members only (non-members hit login/signup wall)\n\n- `\"digital_products\"` = Only buyers of digital-product items\n\nFiner rules (which members, which plans) live in other fields. Do NOT put HTML here. Page body -> `content`; scripts -> `content_footer_html`. No dedicated \"below-body HTML\" field - put below-body markup inside `content` itself." }, "content_menu": { "type": "string", "description": "Menu section this page belongs to" }, "content_order": { "type": "integer", "description": "Sort order within menu/section" }, "content_group": { "type": "string", "description": "Admin-panel grouping label" }, "content_layout": { "type": "integer", "enum": [ 1 ], "description": "**Full Screen Page Width override.** OMIT for normal pages (BD's default container width). Set to `1` for full-bleed pages — individual sections in `content` can then break edge-to-edge (background bands, hero strips, viewport-wide images).\n\n**For full-bleed sections, set `content_layout=1` FIRST.** Do NOT fake full-bleed with negative-margin/9999px-padding tricks in `content_css` — breaks horizontal scroll, fights `overflow: hidden` parents, prevents future layout changes. Anti-pattern.\n\nPattern with `content_layout=1`: scoped CSS in `content_css` gives each section its own edge-to-edge background; inner `<div class=\"container\">` (or page-scoped max-width wrapper) keeps readable copy centered." }, "content_sidebar": { "type": "string", "description": "Sidebar configuration or widget shortcode" }, "menu_layout": { "type": "integer", "enum": [ 1, 2, 3, 4 ], "description": "Sidebar position + width (integer). Only effective when the page has a sidebar set via `form_name` - ignored without sidebar. NOT a navigation menu layout despite the field name.\n\n- `1` = Left Wide (BD default when unspecified)\n\n- `2` = Right Wide\n\n- `3` = Left Slim\n\n- `4` = Right Slim\n\nOrdering is NOT sequential by side - left positions are `1` and `3`, right are `2` and `4`.\n\n**Default on `profile_search_results` pages:** `3` (Left Slim). On `content` pages, omit unless user specifies (BD defaults to `1`)." }, "show_form": { "type": "integer", "enum": [ 0, 1 ], "description": "**Apply NoIndex,NoFollow.** `1` = adds `<meta name=\"robots\" content=\"noindex,nofollow\">` to the page. Auto-applied to protected pages. **NOT a form-render toggle** despite the field name — BD repurposed this column. To render a form in the body, use `[form=<form_name>]` inside `content`." }, "form_name": { "type": "string", "description": "**SIDEBAR name** for this page - BD's field is misnamed; controls sidebar slot, NOT a contact form. NOT for rendering forms on this page — to embed a form in the body, use `[form=<form_name>]` inside `content`. Pass exact sidebar `name` string. `\"\"` = no sidebar. Valid values: a Master Default Sidebar OR a custom sidebar `name` from `listSidebars`. See **Rule: Sidebars** for the canonical Master Default list and selection workflow.\n\n`menu_layout` controls position when `form_name` is set. **Default on `profile_search_results` pages:** `Member Search Result` (NOT `Member Profile Page` - that's for profile/detail pages, not search results)." }, "hide_header_links": { "type": "integer", "enum": [ 0, 1 ], "description": "**Hide Main Menu** - 1 = hides the main navigation menu on this page." }, "hide_header": { "type": "integer", "enum": [ 0, 1 ], "description": "**Hide Header** - 1 = hides the full site header on this page." }, "hide_footer": { "type": "integer", "enum": [ 0, 1 ], "description": "**Hide Footer** - 1 = hides the site footer on this page." }, "hide_top_right": { "type": "integer", "enum": [ 0, 1 ], "description": "**Hide Top Header Menu** - 1 = hides the top-right nav cluster (account/login links)." }, "facebook_title": { "type": "string", "description": "**Social Media Title (Open Graph)** - Title shown when the page is shared on Facebook/LinkedIn/etc. Open Graph title for social sharing" }, "facebook_desc": { "type": "string", "description": "**Social Media Description (Open Graph)** - Description shown on social shares. Open Graph description" }, "facebook_image": { "type": "string", "description": "**Social Media Shared Image** - URL/filename of the OG image. BD recommends at least 200×200px. Open Graph image URL" }, "org_template": { "type": "integer", "description": "**OMIT** — internal layout reference. No public lookup endpoint; setting an arbitrary value can render the page against a nonexistent layout." }, "allowed_products": { "type": "string", "description": "Comma-separated plan/product IDs (empty = all plans)" }, "custom_html_placement": { "type": "string", "enum": [ "0", "1", "2", "3", "4" ], "description": "Render position of `content` HTML relative to dynamic search results. Only meaningful on `seo_type=profile_search_results` (and `data_category`); ignored on `content` pages.\n\n- `0` = Inside Tab (content + members in separate nav tabs)\n\n- `1` = Above Member Results (within results container, sidebar-width)\n\n- `2` = Below Member Results (within results container)\n\n- `3` = Above Body Content (full page width, spans sidebar+results)\n\n- `4` = Below Body Content (full page width, below sidebar+results) <- **recommended default for AI-generated SEO pages**\n\nFor boilerplate SEO intro/FAQ/local copy bolstering thin pages, `4` renders full-width below the live results without disrupting member-facing UX." }, "enable_hero_section": { "type": "string", "enum": [ "0", "1", "2" ], "description": "Hero banner master switch:\n\n- `0` = disabled (all other `hero_*`/`h1_font_*`/`h2_font_*` ignored at render; stored values preserved for later toggle-back)\n\n- `1` = enabled all devices\n\n- `2` = enabled desktop, hidden mobile\n\n**On hero off→on transition (`0`/unset → `1`/`2`), wrapper auto-fills the hero readability bundle** — `hero_top_padding=100`, `hero_bottom_padding=100`, `hero_column_width=5`, `hero_content_overlay_color=rgb(0, 0, 0)`, `hero_content_overlay_opacity=0.5`, `hero_content_font_color=rgb(255, 255, 255)`, `hero_content_font_size=18`, `h1_font_color=rgb(255, 255, 255)`, `h2_font_color=rgb(255, 255, 255)` — for any of those 9 fields you OMITTED. BD's per-field defaults render an unreadable hero (10px content text on a 0.4-opacity overlay, default top/bottom padding 70/60 — visually too cramped for most banner imagery); the bundle is the canonical readable recipe. User-supplied values pass through untouched. Filled fields are echoed in `_hero_bundle_autofilled`. **`hero_image` is required** on transition — wrapper rejects if missing (no safe default; walk the image-sourcing ladder). On no-transition updates (hero already on), no auto-fill fires.\n\n**Homepage benign** - `seo_type=home` ignores hero fields entirely regardless of value. BD stores but never renders on homepage; skip all `hero_*` fields on homepage updates." }, "hero_hide_banner_ad": { "type": "string", "enum": [ "0", "1" ], "description": "When `1`, suppresses the site-wide \"Below Header Banner Ad\" on THIS page only (useful when the hero visually replaces that slot). `0` (default) keeps the banner ad in its normal position." }, "hero_image": { "type": "string", "description": "Hero background image. Accepts BD-hosted relative path (`/images/bg202.webp`) OR external URL (`https://cdn.example.com/banner.jpg`) — external URLs render hotlinked on WebPages, no `auto_image_import` needed. **LANDSCAPE only — never portrait/vertical; source via `https://www.pexels.com/search/<term>/?orientation=landscape`; bare URL, no `?query`, must end in `.jpg`/`.jpeg`/`.png`/`.webp` — see **Rule: Image URLs**.** Query strings get truncated/mangled in BD's form-urlencoded parsing and the stored URL becomes invalid. Recommended dimensions: 1800 × 600 px." }, "hero_background_image_size": { "type": "string", "enum": [ "mobile-ready", "standard" ], "description": "Controls how the hero background image scales/crops across devices. `mobile-ready` (recommended) = responsive behavior tuned for mobile, `standard` = fixed-ratio behavior." }, "hero_content_overlay_color": { "type": "string", "description": "Semi-transparent color layer between hero background image and text, for legibility over busy images. **RGB format ONLY** - `rgb(0, 0, 0)` or `rgb(255, 255, 255)`. Hex (`#000000`) NOT accepted. Combine with `hero_content_overlay_opacity` to control strength. Wrapper auto-fills `rgb(0, 0, 0)` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "hero_content_overlay_opacity": { "type": "string", "enum": [ "0.0", "0.1", "0.2", "0.3", "0.4", "0.5", "0.6", "0.7", "0.8", "0.9", "1" ], "description": "Opacity of `hero_content_overlay_color` layer, 0.1 increments from `0.0` (transparent) to `1` (opaque). Admin UI labels 0-10. BD field default `0.4` is too transparent — wrapper auto-fills `0.5` on hero off→on transition (part of **Rule: Hero readability bundle**). EAV-routed by the wrapper — pass on `updateWebPage` directly, no manual `updateUserMeta` needed." }, "hero_top_padding": { "type": "string", "description": "Top padding inside the hero banner, in pixels. Accepts multiples of 10 from `0` to `200`. BD field default `70` — wrapper auto-fills `100` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "hero_bottom_padding": { "type": "string", "description": "Bottom padding inside the hero banner, in pixels. Accepts multiples of 10 from `0` to `200`. BD field default `60` — wrapper auto-fills `100` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "hero_column_width": { "type": "string", "enum": [ "3", "4", "5", "6", "7", "8", "9", "10", "11", "12" ], "description": "Hero text-content column width as Bootstrap 12-col span. `3`=25%, `4`=30%, `5`=40%, `6`=50%, `7`=60%, `8`=70%, `9`=75%, `10`=80%, `11`=90%, `12`=100%. Narrower = more side padding around the text block. BD field default `8` — wrapper auto-fills `5` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "hero_alignment": { "type": "string", "enum": [ "left", "center", "right" ], "description": "Horizontal alignment of the hero title/subtitle/content text block within its column. Default `center`." }, "h1_font_color": { "type": "string", "description": "Main title (H1) font color in the hero. RGB format ONLY - e.g. `rgb(255, 255, 255)`. The H1 text itself comes from the page's `h1` field - these `h1_*` fields control ONLY the hero's H1 styling. Wrapper auto-fills `rgb(255, 255, 255)` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "h1_font_size": { "type": "string", "description": "Main title (H1) font size in pixels. Accepts integer values from `30` to `80`. OMIT to inherit BD's per-`seo_type` default." }, "h1_font_weight": { "type": "string", "enum": [ "300", "400", "600", "800" ], "description": "Main title (H1) font weight. `300`=Light, `400`=Normal (default), `600`=Bold, `800`=Extra Bold." }, "h2_font_color": { "type": "string", "description": "Sub-title (H2) font color in the hero. RGB format ONLY - e.g. `rgb(255, 255, 255)`. The H2 text itself comes from the page's `h2` field. Wrapper auto-fills `rgb(255, 255, 255)` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "h2_font_size": { "type": "string", "description": "Sub-title (H2) font size in pixels. Accepts integer values from `20` to `60`. OMIT to inherit BD's per-`seo_type` default." }, "h2_font_weight": { "type": "string", "enum": [ "300", "400", "600", "800" ], "description": "Sub-title (H2) font weight. `300`=Light, `400`=Normal, `600`=Bold (default), `800`=Extra Bold." }, "hero_content_font_color": { "type": "string", "description": "Font color for the additional hero content block rendered below H1/H2 (the `hero_section_content` field). RGB format ONLY - e.g. `rgb(0, 0, 0)`. Wrapper auto-fills `rgb(255, 255, 255)` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "hero_content_font_size": { "type": "string", "description": "Font size in pixels for the additional hero content block (`hero_section_content`). Accepts integer values from `10` to `30`. BD field default `10` is too small for hero paragraph copy — wrapper auto-fills `18` on hero off→on transition (part of **Rule: Hero readability bundle**)." }, "hero_section_content": { "type": "string", "description": "Additional text / HTML / widget shortcode rendered BELOW H1 and H2 in the hero section. Supports `[widget=Name]` shortcodes. EAV-routed by the wrapper — pass on `createWebPage` / `updateWebPage` directly, no manual `updateUserMeta` needed." }, "hero_link_url": { "type": "string", "description": "Hero call-to-action (CTA) button link URL. If empty, no CTA button is rendered. For internal links, a relative path is fine (e.g. `/signup`); for external, full URL with `http://` or `https://`." }, "hero_link_text": { "type": "string", "description": "Hero CTA button label. Required (non-empty) for the button to render - `hero_link_url` alone without text will not produce a button." }, "hero_link_target_blank": { "type": "string", "enum": [ "0", "1" ], "description": "When `1`, opens the CTA link in a new tab (`target=\"_blank\"`). `0` (default) opens in the same tab." }, "hero_link_size": { "type": "string", "enum": [ "", "btn-lg", "btn-xl" ], "description": "CTA button size. MUST be exactly one of: `\"\"` (empty = Normal), `btn-lg` (Large), `btn-xl` (Extra Large). Any other value (e.g. a font-size number like `16`) is stored verbatim and rendered as a broken class — BD does not validate server-side." }, "hero_link_color": { "type": "string", "enum": [ "primary", "info", "success", "warning", "danger", "default", "secondary" ], "description": "CTA button color variant — attention level, not literal color. MUST be exactly one of: `primary`, `info`, `success`, `warning`, `danger`, `default`, `secondary`. Any other value (e.g. a hex `#ffffff`) is stored verbatim and rendered as a broken class like `btn-#ffffff` — BD does not validate server-side.\n\nChoose by attention level needed: `primary` (main CTA), `danger` (urgent/can't-miss), `warning` (attention), `success` (positive action), `info` (neutral-blue), `secondary` (theme secondary), `default` (low-emphasis gray). Actual rendered color comes from the site's theme palette." }, "linked_post_type": { "type": "string", "description": "Post type's `data_id` (from `listPostTypes`). REQUIRED when `seo_type=data_category`; ignored on other seo_types. See **Rule: Resource disambiguation** when the user names a post type by description rather than `data_id`." }, "linked_post_category": { "type": "string", "description": "Either the literal `post_main_page` (pins to the post type's main search-results page) OR an exact category name from the linked post type's `feature_categories` (e.g. `\"Category 1\"`, case-sensitive). Optional on `seo_type=data_category` — wrapper auto-defaults to `post_main_page` when omitted on a fresh data_category create or content→data_category switch. Ignored on other seo_types. Wrapper enforces pair-uniqueness on `(linked_post_type, linked_post_category)`." }, "disable_css_stylesheets": { "type": "integer", "enum": [ 0, 1 ], "description": "Disable BD's site stylesheets on this page (frontend only). `1` = page renders without BD's global CSS (use when embedding a fully self-styled custom page or iframe target). `0` (default) = normal BD styling. EAV-stored — agent passes directly; wrapper handles routing on update." }, "private_page_select": { "type": "string", "description": "Access control setting" }, "page_render_widget": { "type": "string", "description": "**OMIT** — internal widget reference for `seo_type=custom_widget_page` only. No public widget-ID lookup; setting on other page types breaks rendering." }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing `list_seo` page by `seo_id`. PATCH semantics - omitted fields untouched.\n\n**Cache refresh is automatic.** Every successful `updateWebPage` / `createWebPage` triggers `refreshCache(scope=web_pages)` server-side; the response includes `auto_cache_refreshed: true`. Do not call `refreshSiteCache` manually after — it's already done. If `auto_cache_refreshed: false` appears in the response, the write succeeded but cache flush failed; check `auto_cache_refresh_error` and retry `refreshSiteCache` once.\n\n**Required fields:** `seo_id`. When changing `seo_type` to `data_category`, `linked_post_type` is also required (auto-validated at runtime).\n\n**Disambiguation:** apply **Rule: Resource disambiguation** when the user names a page by `title` or partial `filename` rather than by `seo_id`. \"Edit my [X] page\" is layer-ambiguous — could be a `seo_type=content` WebPage, a post type's code group, or a category landing.\n\n**Common edits:**\n\n- Copy/content: `content`, `h1`, `h2`, `seo_text`, `content_footer` (access gate, not HTML)\n\n- SEO meta: `title`, `meta_desc`, `meta_keywords`\n\n- Social (Open Graph): `facebook_title`, `facebook_desc`, `facebook_image`\n\n- Layout: `content_layout`, `hide_header`, `hide_footer`, `hide_top_right`, `hide_header_links`\n\n- Hero: `enable_hero_section` + `hero_*` + `h1_*`/`h2_*`\n\n**Misnamed fields (BD repurposed these columns - name is misleading):**\n\n- `show_form` -> **Apply NoIndex,NoFollow**. NOT a contact-form toggle. `1`=add `<meta name=\"robots\" content=\"noindex,nofollow\">`.\n\n- `content_footer` -> **page access gate** enum: `\"\"` (public) / `\"members_only\"` / `\"digital_products\"`. NOT footer HTML.\n\n- `form_name` -> **sidebar name**. NOT a contact-form slug.\n\n- `seo_text` -> **Wildcard URL Rewrite** (catch-all routing). NOT SEO copy.\n\n**Template tokens supported** in `title`, `meta_desc`, `meta_keywords`, `h1`, `h2`: `%%%website_name%%%`, `%industry%`, `%profession%`. Expanded at render time.\n\n**Changing `filename` (URL slug) breaks inbound links** - call `createRedirect` to create a 301 from old slug -> new slug, preserve SEO. **Pre-check new slug for duplicate before renaming** - `listWebPages property=filename property_value=<new-slug> property_operator==`. BD does NOT enforce unique `filename`; renaming to an existing slug silently creates two records at the same URL, render order undefined.\n\n**Asset field routing (Froala strips mismatched content silently):**\n\n- `content` - body HTML only. No `<style>`/`<script>` tags. Supports `[widget=Name]` + `%%%token%%%`.\n\n- `content_css` - raw CSS, no `<style>` wrapper, scope to a unique page class. Never target `.container`/`.froala-table`/`.image-placeholder`. Never `@import` (causes FOUC/CLS - use `content_head` `<link>` tag instead).\n\n- `content_footer_html` - JavaScript + script embeds (`<script>` tags OK). IIFE-wrap + scope.\n\n- `content_head` - head-only deps (`<link>`, `<meta>`, JSON-LD, external stylesheets, fonts).\n\n- `content_footer` - MISLEADING NAME. Access gate enum only: `\"\"`/`\"members_only\"`/`\"digital_products\"`. NOT HTML.\n\n- SVG/canvas prohibited in `content` - Froala strips. Charts/diagrams -> custom Widget via `[widget=Name]`.\n\nAll asset fields accept raw content verbatim. No CDATA, no `<parameter>`/`<invoke>`/`<function_calls>` scaffolding, no entity-escaped HTML — forbidden anywhere in the value, not just as wrappers. Server strips these as a safety net; do not rely on it.\n\n**EAV-stored fields — auto-routed by the wrapper, no special handling.** BD's `list_seo` table mixes direct columns with EAV-stored fields in `users_meta`. BD's REST API itself silently ignores EAV fields on update, but the wrapper auto-detects fields like `hero_*`, `h1_*`, `h2_*`, `linked_post_category`, `disable_*` and routes them through `users_meta` automatically. Pass any field on `updateWebPage` directly — the response includes an `eav_results` array confirming which EAV fields were written. Reads merge automatically via `getWebPage` / `listWebPages`. Do NOT call `updateUserMeta` directly for these. If a field doesn't persist after `updateWebPage`, file as a wrapper bug (the wrapper's EAV routing table needs the field added) rather than working around with manual `updateUserMeta`.\n\n**Hero section edits - when `enable_hero_section` toggles from 0/unset to `1` or `2`, apply **Rule: Hero readability bundle** (atomic — all listed values must be sent together unless user overrides). Notes:\n\n- All color fields RGB ONLY, hex rejected.\n\n- `h1_*`/`h2_*` fields style hero; text comes from record's top-level `h1`/`h2`.\n\n- Hero image: Pexels stock; see **Rule: Image URLs** (imported field — bare URL, no query string). Never `picsum.photos`/`lorempixel`/`placekitten`.\n\n- Hero gap-fix CSS (`seo_type=content` ONLY): add `.hero_section_container + div.clearfix-lg {display:none}` to `content_css` (closes BD's 40px clearfix gap). **Never add this rule on any other `seo_type`** - on `profile_search_results` / `data_category` the clearfix provides needed spacing before live search-results.\n\n- Hero is cache-gated but `updateWebPage`/`createWebPage` auto-refresh handles it; no separate call needed.\n\n- **Homepage hero is BENIGN**: `seo_id=1` stores hero fields but homepage template does NOT render them. Skip hero fields on homepage unless user explicitly asks.\n\nDo NOT re-apply hero defaults on updates that don't touch `enable_hero_section` - respect the user's existing color/overlay/padding values; only change what they asked about.\n\n**H1/H2 double-render trap:** when hero is enabled (`enable_hero_section=1` or `2`), the record's top-level `h1`/`h2` text renders inside the hero banner. If `content` ALSO contains `<h1>`/`<h2>`, BOTH render -> duplicate headings (bad for SEO). Rule: either put headings in record's `h1`/`h2` fields (leave them out of `content`), OR put them in `content` (leave `h1`/`h2` fields empty). Never both.\n\n**`profile_search_results` updates - all create-time rules apply:**\n\n- `filename` must be a real dynamic slug BD's router recognizes — see **Rule: Member search SEO pages** for the slug hierarchy (`country/state/city/top_cat/sub_cat`) and the live-lookup endpoints. Arbitrary slugs render 404. Country-only slug does NOT render for `profile_search_results`. For arbitrary-URL pages use `seo_type=content`.\n\n- Required defaults on every write: `custom_html_placement=4` (Below Body Content), `form_name=\"Member Search Result\"` (sidebar - Master Default; NOT \"Member Profile Page\"), `menu_layout=3` (Left Slim).\n\n- `custom_html_placement` is only meaningful on `profile_search_results` (and `data_category`). Ignored on `content` pages.\n\n- Auto-generate SEO meta for the specific combo - don't leave blank:\n - `title` - 50-60 chars ideal, <=70 max. Pattern: `\"[Category] in [City], [State] | [Site Name]\"`.\n - `meta_desc` - 150-160 chars ideal, <=170 max. 1-2 sentences with location + CTA.\n - `meta_keywords` - ~200 chars, comma-separated (no spaces).\n - `facebook_title` - 55-60 chars, differ from `title` (more conversational).\n - `facebook_desc` - 110-125 chars, punchier than `meta_desc`.\n - Do NOT auto-set `facebook_image` (needs uploaded asset).\n\n- No max-width wrappers in `content` or `content_css` - BD's layout already provides the outer container; `max-width: 960px; margin: auto` double-constrains to a narrow strip. Let content flow at natural width.\n\n**SEO content for categories:** route to `updateWebPage seo_type=profile_search_results` (NOT `updateTopCategory.desc` / `updateSubCategory.desc` - those are internal labels, not rendered).\n\n**`seo_type` enum:** `content`, `home`, `profile_search_results`, `data_category`, `custom_widget_page`, `password_retrieval_page`, `unsubscribed`. OMIT on update unless intentionally changing it — most changes are destructive. `home` appears in the enum only so existing-record round-trips pass validation; never convert another page TO `home` (only one homepage per site).\n\n**On `deleteWebPage`:** BD does NOT cascade-delete users_meta rows. After deleting, run the orphan cleanup per **Rule: users_meta orphans** - `listUserMeta` filtered by `database=list_seo` + `database_id=<deleted seo_id>`, then `deleteUserMeta` each match. **Exception:** for `seo_type=data_category` pages the wrapper auto-strips `linked_post_type` / `linked_post_category` rows on transition AWAY from data_category, AND auto-cascade-deletes the placeholder-slug 301 redirect on `deleteWebPage` (annotations: `_data_category_orphans_stripped`, `_data_category_redirect_retired`, `_data_category_redirect_deleted`). Hero / layout EAV rows still need manual cleanup.\n\n**See also:** `createWebPage` (new page), `deleteWebPage` (remove + orphan users_meta cleanup), `createRedirect` (slug-change preservation).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord}, auto_cache_refreshed: true|false, auto_cache_refresh_error?: \"...\", _admin_edit_url: \"...\" }`. `auto_cache_refreshed` reports whether the automatic cache flush succeeded; if `false`, `auto_cache_refresh_error` explains why and the agent should retry `refreshSiteCache` manually once. `_admin_edit_url` is a centralized-admin deep-link to the WebPage editor for this `seo_id` — surface it to the user so they can jump straight to the admin edit screen for the page just updated." } }, "/api/v2/list_seo/delete": { "delete": { "operationId": "deleteWebPage", "summary": "Delete a page", "tags": [ "Pages" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "seo_id" ], "properties": { "seo_id": { "type": "integer", "description": "Page primary key" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a web page by `seo_id`. Destructive - cannot be undone via API.\n\n**Use when:** permanently removing a page.\n\n**Required:** `seo_id`.\n\n**Destructive:** confirm with the user. If inbound links or menus reference the deleted page's URL, consider creating a `createRedirect` BEFORE deleting so those links don't 404. Menus with items pointing at this page also need cleanup.\n\n**`seo_type=data_category` cascade — automatic.** When the deleted page is data_category, the wrapper auto-deletes its placeholder-slug 301 redirect (response includes `_data_category_redirect_deleted: <redirect_id>`). The agent does not need to call `deleteRedirect` manually.\n\n**ORPHAN CLEANUP REQUIRED - BD does NOT cascade-delete users_meta rows.** Each WebPage with a hero or custom layout has up to 18 EAV rows in `users_meta` (`database=list_seo`, `database_id=<seo_id>`). These persist after `deleteWebPage` unless you clean them up. Safe post-delete workflow:\n 1. Call `listUserMeta` filtered on `database=list_seo` AND `database_id=<deleted seo_id>`.\n 2. **Client-side filter the response** - keep only rows whose `database` field equals `list_seo`. The same `database_id` value may exist in `users_meta` pointing at unrelated parent tables (e.g. a member with `user_id=<seo_id>` in `users_data`), and those rows must NOT be deleted.\n 3. For each remaining row, call `deleteUserMeta` with `meta_id=<row.meta_id>`, `database=list_seo`, `database_id=<seo_id>` (all three required).\n\nNever loop-delete by `database_id` alone - you will silently destroy unrelated records on other tables.\n\n**See also:** `updateWebPage` (modify without removing).\n\n**Returns:** `{ status: \"success\", message: \"list_seo record was deleted\" }`.\n\n" } }, "/api/v2/redirect_301/get": { "get": { "operationId": "listRedirects", "summary": "List redirects (301)", "description": "Paginated list of all 301 redirect rules on the site.\n\n**Use when:** auditing existing 301 rules - useful before bulk URL changes to avoid duplicate rules, or when debugging why a URL unexpectedly redirects.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getRedirect` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n", "tags": [ "Redirects" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } } } }, "/api/v2/redirect_301/get/{redirect_id}": { "get": { "operationId": "getRedirect", "summary": "Get a single redirect", "tags": [ "Redirects" ], "parameters": [ { "name": "redirect_id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Redirect primary key" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single redirect record. Read-only.\n\n**Use when:** investigating one specific redirect rule by `redirect_id`.\n\n**Required:** `redirect_id`.\n\n**See also:** `listRedirects` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/redirect_301/create": { "post": { "operationId": "createRedirect", "summary": "Create a redirect", "description": "Create a new 301 redirect rule.\n\n**Use when:** preserving SEO after any URL change - slug rename on a member profile, post, page, or category. BD auto-creates some redirects on its own (admin-triggered renames), but you must create them manually for API-triggered changes. Avoid duplicate `old_filename` values.\n\n**Required:** `old_filename`, `new_filename`.\n\n**`type` is wrapper-managed:** the wrapper hardcodes `type=custom` on every create. The other BD `type` values (`profile`, `post`, `category`) are reserved for BD's own auto-redirect logic on admin-triggered renames and are not exposed here.\n\n**Pre-check before create - TWO checks (redirects are uniquely dangerous: wrong rules cause infinite loops and SEO damage):**\n\n**Check 1 - exact-pair skip (idempotent):** Do a **server-side filter-find**: `listRedirects property=old_filename property_value=<proposed old> property_operator==`. If a row exists where `new_filename` also matches the proposed new, **skip the create** - the rule is already there; creating a duplicate just bloats the redirect table. If a row exists with the same `old_filename` but a DIFFERENT `new_filename`, that's a conflict: reuse via `updateRedirect` with the new target, OR ask the user which destination wins. Never silently create a duplicate or conflicting `old_filename`.\n\n**Check 2 - reverse-rule loop prevention (CRITICAL):** Do a second filter-find: `listRedirects property=old_filename property_value=<proposed NEW> property_operator==`. If a row exists where `new_filename` equals your proposed `old_filename`, creating this rule would produce an **infinite redirect loop** (A->B and B->A). **STOP.** Flag to the user, explain the reverse rule in place, and ask whether to delete the existing reverse rule first or abandon the create.\n\n**Do NOT paginate unfiltered redirect lists** - filtered lookups are two tiny responses. On any site with a large redirect history, dumping the full table wastes rate limit and context.\n\n**Parameter interactions:**\n\n- `old_filename` / `new_filename` - URL paths relative to the domain root (not full URLs)\n\n- `db_id` - database record ID of the source content if the redirect ties to one; otherwise 0\n\n**See also:** `updateRedirect` (modify existing).\n\n", "tags": [ "Redirects" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "old_filename", "new_filename" ], "properties": { "old_filename": { "type": "string", "description": "The old URL path being redirected from, relative to the domain root (e.g. old-slug, not the full URL)" }, "new_filename": { "type": "string", "description": "The new destination URL path" }, "db_id": { "type": "integer", "description": "Database record ID of the source content object this redirect was generated from (0 if not tied to a record)" }, "id": { "type": "integer", "description": "Legacy secondary identifier; typically 0 for system-generated redirects" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } } } }, "/api/v2/redirect_301/update": { "put": { "operationId": "updateRedirect", "summary": "Update a redirect", "tags": [ "Redirects" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "redirect_id" ], "properties": { "redirect_id": { "type": "integer", "description": "Redirect primary key (required to identify record)" }, "old_filename": { "type": "string", "description": "The old URL path being redirected from, relative to the domain root (e.g. old-slug, not the full URL)" }, "new_filename": { "type": "string", "description": "The new destination URL path" }, "db_id": { "type": "integer", "description": "Database record ID of the source content object this redirect was generated from (0 if not tied to a record)" }, "id": { "type": "integer", "description": "Legacy secondary identifier; typically 0 for system-generated redirects" }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing redirect record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** adjusting an existing rule's destination or source path. Rare - most redirects are create-once.\n\n**Required:** `redirect_id`.\n\n**`type` is wrapper-managed:** not exposed as an input. All redirects created via this MCP are `custom`; other BD `type` values are reserved for system-generated redirects.\n\n**See also:** `createRedirect` (add new), `deleteRedirect` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/redirect_301/delete": { "delete": { "operationId": "deleteRedirect", "summary": "Delete a redirect", "tags": [ "Redirects" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "redirect_id" ], "properties": { "redirect_id": { "type": "integer", "description": "Redirect primary key" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a redirect record by ID. Destructive - cannot be undone via API.\n\n**Use when:** an old redirect is no longer needed (source content has been offline long enough that the 301 value is gone) or the rule is conflicting with a new page at the same path.\n\n**Required:** `redirect_id`.\n\n**See also:** `updateRedirect` (modify without removing).\n\n**Destructive:** confirm intent with the user before bulk use. No soft-delete via API - records removed are not recoverable.\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n" } }, "/api/v2/website_settings/refreshCache": { "post": { "operationId": "refreshSiteCache", "summary": "Refresh the site cache (template/theme/widget/menu/page invalidation)", "description": "Clears BD's internal template/theme/widget caches. Useful when recent admin edits to design settings or widgets aren't showing on the public site yet.\n\n**Use when:** the user has just updated a template, theme setting, widget, menu, or page layout and the public site is still serving the old version. Also a safe troubleshooting step if they report a recent admin-edit not appearing after ~1 minute.\n\n**Optional parameters:**\n\n- `scope` - target one cache area only (`data_widgets`, `settings`, `web_pages`, `css`, `menus`, `sidebars`). Faster than a full refresh. Omit to refresh all 6.\n\n- `full=1` - include heavier db_optimization + file_permissions passes in addition to the 6 core areas. Slower; use only when the user reports persistent issues and lighter refreshes didn't help.\n\n**Not needed after `createWebPage` / `updateWebPage` / `createWidget` / `updateWidget`** — those tools auto-refresh and return `auto_cache_refreshed: true` in the response. Only call manually if a write returned `auto_cache_refreshed: false` (check `auto_cache_refresh_error` for the cause).\n\n**Do NOT use for:**\n\n- Routine workflow noise - do not call after every bulk op on non-page resources. Most BD writes unrelated to pages are live immediately; cache invalidation is a targeted fallback, not a default post-step.\n\n**Returns:** `{ status: \"success\", message: \"Cache refreshed successfully\", areas_refreshed: [...], scope: \"full\", full: false }`. The `areas_refreshed` array lists exactly what was cleared - useful for logging or reporting back to the user. Example default response:\n```json\n{\n \"status\": \"success\",\n \"message\": \"Cache refreshed successfully\",\n \"areas_refreshed\": [\"data_widgets\", \"settings\", \"web_pages\", \"css\", \"menus\", \"sidebars\"],\n \"scope\": \"full\",\n \"full\": false\n}\n```\n\nWith `full=1` the `areas_refreshed` additionally includes `db_optimization` and `file_permissions`. Invalid `scope` values return an error listing the valid set: `{ status: \"error\", message: \"Invalid scope value: <x>. Valid values: ...\" }`.\n\nUndocumented by BD publicly; exposed via admin API-permissions UI.\n\n", "tags": [ "Website Settings" ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "requestBody": { "required": false, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "properties": { "scope": { "type": "string", "enum": [ "data_widgets", "settings", "web_pages", "css", "menus", "sidebars" ], "description": "Target a specific cache area instead of refreshing all 6. Omit to refresh all. Valid values: data_widgets, settings, web_pages, css, menus, sidebars. Invalid scope returns an error listing the valid set." }, "full": { "type": "integer", "enum": [ 0, 1 ], "description": "Pass 1 to also run db_optimization + file_permissions refresh (heavier, slower). Pass 0 or omit for standard refresh of the 6 core areas only." } } } } } } } }, "/api/v2/data_types/get": { "get": { "operationId": "listDataTypes", "summary": "List data types", "description": "List all data types configured on this BD site. Use the data_id values as data_type parameters when creating posts or portfolio groups.\n\n**Use when:** discovering the valid `data_type` values on the site. Used as a prerequisite lookup when creating posts or portfolio groups that need a `data_type` foreign-key value.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getDataType` (single record by ID).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record is the full resource object.\n\n", "tags": [ "Data Types" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } } } }, "/api/v2/data_types/get/{data_id}": { "get": { "operationId": "getDataType", "summary": "Get a single data type", "tags": [ "Data Types" ], "parameters": [ { "name": "data_id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Data type primary key" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single datatype record. Read-only.\n\n**Use when:** fetching one data type's record by ID.\n\n**Required:** `data_id`.\n\n**See also:** `listDataTypes` (enumerate many).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - the `message` array contains 1 record when found. Empty or HTTP 404 when not found.\n\n" } }, "/api/v2/data_types/create": { "post": { "operationId": "createDataType", "summary": "Create a data type", "description": "Define a new content-type template. Only do this when the user explicitly wants a new post type - most sites come pre-configured with the types they need.\n\n**Use when:** adding a new data-type classifier. Rare - usually preconfigured.\n\n**Required:** `category_name`, `category_active`.\n\n**Pre-check before create:** BD does NOT enforce uniqueness on `category_name` or the derived `system_name`. Duplicate data types corrupt the post-type admin UI, break post-listing widgets that bind by name, and risk posts landing under the wrong type. Do a **server-side filter-find**: `listDataTypes property=category_name property_value=<proposed> property_operator==`. Zero rows = name free; >=1 row = taken. **Do NOT paginate unfiltered lists** - filtered lookup is one tiny response. If taken: reuse via `updateDataType`, OR ask the user, OR pick an alternate `category_name` and re-check. Never silently create a duplicate.\n\n**Enums:** `category_active`: `1`=active and available for members to use, `0`=inactive; `limit_available`: `0`, `1`.\n\n**See also:** `updateDataType` (modify existing).\n\n", "tags": [ "Data Types" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "category_name", "category_active" ], "properties": { "category_name": { "type": "string", "description": "Display name for this content type (e.g. \"Single Photo Post\", \"Multi-Photo Post\", \"Video Post\")" }, "category_active": { "type": "integer", "enum": [ 0, 1 ], "description": "1 = active and available for members to use; 0 = inactive" }, "limit_available": { "type": "integer", "enum": [ 0, 1 ], "description": "1 = membership-plan posting limits apply to this data type; 0 = no per-plan limits" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } } } }, "/api/v2/data_types/update": { "put": { "operationId": "updateDataType", "summary": "Update a data type", "tags": [ "Data Types" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "data_id" ], "properties": { "data_id": { "type": "integer", "description": "Data type primary key (required to identify the record)" }, "category_name": { "type": "string", "description": "Display name for this content type (e.g. \"Single Photo Post\", \"Multi-Photo Post\", \"Video Post\")" }, "category_active": { "type": "integer", "enum": [ 0, 1 ], "description": "1 = active and available for members to use; 0 = inactive" }, "limit_available": { "type": "integer", "enum": [ 0, 1 ], "description": "1 = membership-plan posting limits apply to this data type; 0 = no per-plan limits" }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing datatype record by ID. Fields omitted are untouched. Writes live data.\n\n**Use when:** renaming a data type.\n\n**Required:** `data_id`.\n\n**Enums:** `category_active`: `1`=active and available for members to use, `0`=inactive; `limit_available`: `0`, `1`.\n\n**See also:** `createDataType` (add new), `deleteDataType` (remove permanently).\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }` - the full updated record after changes applied.\n\n" } }, "/api/v2/data_types/delete": { "delete": { "operationId": "deleteDataType", "summary": "Delete a data type", "description": "Deletes a data type definition. Records (posts, portfolio groups) referencing a deleted data type may become orphaned - confirm with the user before deleting.\n\n**Use when:** removing an unused data type. Posts/groups referencing it orphan - clean up first.\n\n**Required:** `data_id`.\n\n**See also:** `updateDataType` (modify without removing).\n\n**Returns:** `{ status: \"success\", message: \"record was deleted\" }`. No body beyond the confirmation string.\n\n", "tags": [ "Data Types" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "data_id" ], "properties": { "data_id": { "type": "integer", "description": "Data type primary key" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } } } }, "/api/v2/list_professions/get": { "get": { "operationId": "listTopCategories", "summary": "List categories (professions)", "tags": [ "Categories" ], "parameters": [ { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/include_category_schema" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedResponse" } } } } }, "description": "Paginated enumeration of TOP-level member categories. Read-only.\n\n**Lean by default:** each row keeps `profession_id`, `name`, `filename`. Strips `desc`, `keywords`, `image`, `icon`, `sort_order`, `lead_price`, `revision_timestamp`. Pass `include_category_schema=1` to restore all category metadata.\n\n\nTop Categories are the highest level of the 3-tier member classification (e.g., \"Restaurants\", \"Dentists\"). Each record's `profession_id` is what populates `users_data.profession_id` on member records. Backed by BD's `list_professions` table.\n\n**Use when:** populating a category dropdown, generating a site map, or discovering the `profession_id` of an existing category before assigning members to it. Returns ALL top-level categories. For sub-categories under a specific top, use `listSubCategories` with a `profession_id` filter.\n\n**Permission note - platform gap:** this endpoint (`/api/v2/list_professions/*`) is NOT in BD's public Swagger spec, so the admin's API key permissions UI does NOT auto-generate a toggle for it. New keys default to DENY on this path even if the admin enables the \"Categories (Professions)\" toggle - that UI toggle gates the Swagger-documented `/api/v2/category/*` endpoints (a DIFFERENT legacy table). On a 403 here: the fix is to MANUALLY INSERT a row into `bd_api_key_permissions` for `endpoint_path='/api/v2/list_professions/get'` (and the singular `/api/v2/list_professions/get/{profession_id}` for `getTopCategory`). This is a platform-level gap worth reporting to BD dev team. Do NOT substitute `/api/v2/category/*` as a fallback - it reads a different, possibly-empty table and returns inconsistent data.\n\n**Pagination:** cursor-based (`limit`, `page`). See **Rule: Pagination** for full cursor/cap/stop semantics.\n\n**Filter/sort:** `property`+`property_value`+`property_operator`, `order_column`+`order_type`. See **Rule: Filter operators** for the verified-working operator set, silent-drop detection, and derived-field unfilterability.\n\n**See also:** `getTopCategory` (single by ID), `listSubCategories` (sub-categories filtered by `profession_id`), `createTopCategory` (add new).\n\n**Returns:** `{ status: \"success\", total, current_page, total_pages, next_page, prev_page, message: [...records] }`. Each record has `profession_id`, `name`, `desc`, `filename`, `keywords`, `icon`, `sort_order`, `lead_price`, `image`, `revision_timestamp`.\n\n\n---\n\n**Member Category Hierarchy (3 levels):**\n\nBD classifies members through a 3-level taxonomy - AI agents MUST understand all three to correctly create, assign, and query member categories:\n\n| Level | Tool nicknames | Endpoint | BD internal table | Key field | Parent reference |\n|---|---|---|---|---|---|\n| **1. Top Category** | `listTopCategories`, `createTopCategory`, etc. | `/api/v2/list_professions/*` | `list_professions` | `profession_id` (PK) | - |\n| **2. Sub Category** | `listSubCategories`, `createSubCategory`, etc. | `/api/v2/list_services/*` | `list_services` | `service_id` (PK) | `profession_id` -> parent Top Category; `master_id` -> parent Sub Category (for sub-sub nesting; 0 = direct child of Top) |\n| **3. Member ↔ Sub Category link** | `listMemberSubCategoryLinks`, `createMemberSubCategoryLink`, etc. | `/api/v2/rel_services/*` | `rel_services` | `rel_id` (PK) | `user_id` -> member; `service_id` -> Sub Category |\n\n**How a member gets classified on their public profile:**\n\n- `users_data.profession_id` -> points at a single Top Category (the member's primary classification; shown in URL slug)\n\n- `users_data.services` -> CSV of Sub Category IDs the member is tagged with (multiple allowed; simpler than the join table)\n\n- `rel_services` rows (Member ↔ Sub Category links) -> used when you need per-link metadata like `avg_price`, `specialty`, `num_completed`. Optional; most sites use just the CSV field.\n\n**Sub-sub-categories:** `createSubCategory` with `master_id=<parent service_id>` creates a Sub Category nested under another Sub Category (a \"sub-sub\"). `master_id=0` (default) means the Sub Category sits directly under a Top Category (the `profession_id`).\n\n**There is NO `createProfession` or `createService` tool in this MCP** — those are BD's internal table names. Use `createTopCategory` / `createSubCategory` instead (BD's table-name → tool-name mapping is documented in **Rule: Table to endpoint**).\n\n\n" } }, "/api/v2/list_professions/get/{profession_id}": { "get": { "operationId": "getTopCategory", "summary": "Get a single category", "tags": [ "Categories" ], "parameters": [ { "name": "profession_id", "in": "path", "required": true, "schema": { "type": "integer" } }, { "$ref": "#/components/parameters/include_category_schema" } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Fetch a single TOP-level member category by `profession_id`. Read-only.\n\n**Lean by default:** keeps `profession_id`, `name`, `filename`. Strips SEO metadata. Pass `include_category_schema=1` to restore.\n\n\nA Top Category is the highest level of the 3-tier member classification. Backed by BD's `list_professions` table.\n\n**Use when:** you already have a `profession_id` and need its full record (name, filename, etc.). For enumeration use `listTopCategories`.\n\n**Required:** `profession_id` (path parameter).\n\n**See also:** `listTopCategories` (enumerate), `listSubCategories` (sub-categories under this one; filter by `profession_id`).\n\n**Returns:** `{ status: \"success\", message: [{...record}] }` - array of 1 record with full fields.\n\n\n\n**How a member gets classified on their public profile:**\n\n- `users_data.profession_id` -> points at a single Top Category (the member's primary classification; shown in URL slug)\n\n- `users_data.services` -> CSV of Sub Category IDs the member is tagged with (multiple allowed; simpler than the join table)\n\n- `rel_services` rows (Member ↔ Sub Category links) -> used when you need per-link metadata like `avg_price`, `specialty`, `num_completed`. Optional; most sites use just the CSV field.\n\n**Sub-sub-categories:** `createSubCategory` with `master_id=<parent service_id>` creates a Sub Category nested under another Sub Category (a \"sub-sub\"). `master_id=0` (default) means the Sub Category sits directly under a Top Category (the `profession_id`).\n\n**There is NO `createProfession` or `createService` tool in this MCP** — those are BD's internal table names. Use `createTopCategory` / `createSubCategory` instead (BD's table-name → tool-name mapping is documented in **Rule: Table to endpoint**).\n\n\n" } }, "/api/v2/list_professions/create": { "post": { "operationId": "createTopCategory", "summary": "Create a category", "tags": [ "Categories" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "name", "filename" ], "properties": { "name": { "type": "string", "description": "Top-level category name (e.g. \"Restaurants\", \"Dentists\")." }, "filename": { "type": "string", "description": "URL-slug form of the name (e.g. \"restaurants\"). Used in public profile URL paths." }, "desc": { "type": "string", "description": "Short internal taxonomy-row label. **Even if the user says \"description\" - this is NOT an SEO description.** Most BD themes don't render this field. For SEO copy on the Top-Category public search page (H1, intro, meta tags), create a WebPage with `seo_type=profile_search_results` + matching slug (see `createWebPage`). Short internal blurb only here." }, "keywords": { "type": "string", "description": "Fuzzy-search synonyms for on-site category matching - NOT SEO meta-keywords. Comma-separated single words (no spaces): synonyms, abbreviations, slang, common misspellings. Example for `Doctor`: `doc,physician,md,medic,gp,specialist`. ~5-10 max. Skip SEO phrases like `doctor near me` - those aren't fuzzy matchers. Optional." }, "icon": { "type": "string", "description": "Icon identifier (e.g. a Font Awesome class or image filename)." }, "sort_order": { "type": "integer", "description": "Display order among top-level categories. Lower = earlier." }, "lead_price": { "type": "number", "description": "Per-lead price charged for leads matching this category (decimal)." }, "image": { "type": "string", "description": "Image filename for the category icon/banner." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Create a new TOP-level member category. Writes live data.\n\nA Top Category is the highest level of the 3-tier member classification (e.g., \"Restaurants\"). It populates the `profession_id` field on user records. Backed by BD's `list_professions` table.\n\n**Use when:** explicitly building out the site's taxonomy BEFORE members exist. If you're creating members and want categories to be auto-created by name, skip this and pass `profession_name` directly to `createUser` - BD auto-creates missing top-level categories on member-create.\n\n**Required:** `name`, `filename`.\n\n**Pre-check before create:** BD does NOT enforce uniqueness on `filename`. Two top categories with the same slug -> which one resolves at `/filename` is undefined. Do a **server-side filter-find**: `listTopCategories property=filename property_value=<proposed> property_operator==`. Zero rows = slug free; >=1 row = taken. **Do NOT paginate unfiltered lists** - filtered lookup is one tiny response. If taken: reuse via `updateTopCategory`, OR ask the user, OR pick an alternate `filename` and re-check. **Wrapper safety net:** on a missed pre-check, the wrapper auto-suffixes `filename` on collision (`-1`...`-20`) and surfaces the suffix in the response. Pre-checking still preferred — auto-suffix surprises the caller in URL-sensitive workflows.\n\n**Parameter guidance:**\n\n- `name` - human-readable (e.g. \"Restaurants\", \"Dentists\")\n\n- `filename` - URL-slug form (e.g. \"restaurants\") used in public member profile URLs\n\n- `desc`, `keywords`, `icon`, `sort_order`, `lead_price`, `image` - all optional\n\n**See also:** `listTopCategories` (list all), `getTopCategory` (by ID), `createSubCategory` (add a sub-category under this top).\n\n**Writes live data:** changes are immediately visible on the public site.\n\n**Returns:** `{ status: \"success\", message: {...createdRecord} }` including the new `profession_id`. Use that value to populate `users_data.profession_id` on member records.\n\n**Common workflow - full 3-tier setup (\"create Restaurants -> Sushi -> assign Alice\"):**\n\n1. `createTopCategory` with `name=\"Restaurants\"`, `filename=\"restaurants\"` -> returns `profession_id` (e.g. 42)\n2. `createSubCategory` with `name=\"Sushi\"`, `profession_id=42`, `filename=\"sushi\"` -> returns `service_id` (e.g. 17)\n3. Assign Alice: `updateUser` with `user_id=<Alice>`, `profession_id=42`, `services=\"17\"` (simple), OR `createMemberSubCategoryLink` with `user_id=<Alice>`, `service_id=17`, `avg_price=...`, `specialty=1` (with per-link metadata)\n\n\n\n**How a member gets classified on their public profile:**\n\n- `users_data.profession_id` -> points at a single Top Category (the member's primary classification; shown in URL slug)\n\n- `users_data.services` -> CSV of Sub Category IDs the member is tagged with (multiple allowed; simpler than the join table)\n\n- `rel_services` rows (Member ↔ Sub Category links) -> used when you need per-link metadata like `avg_price`, `specialty`, `num_completed`. Optional; most sites use just the CSV field.\n\n**Sub-sub-categories:** `createSubCategory` with `master_id=<parent service_id>` creates a Sub Category nested under another Sub Category (a \"sub-sub\"). `master_id=0` (default) means the Sub Category sits directly under a Top Category (the `profession_id`).\n\n**There is NO `createProfession` or `createService` tool in this MCP** — those are BD's internal table names. Use `createTopCategory` / `createSubCategory` instead (BD's table-name → tool-name mapping is documented in **Rule: Table to endpoint**).\n\n\n" } }, "/api/v2/list_professions/update": { "put": { "operationId": "updateTopCategory", "summary": "Update a category", "tags": [ "Categories" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "profession_id" ], "properties": { "profession_id": { "type": "integer", "description": "The top-level category ID to update (from createTopCategory / listTopCategories)." }, "name": { "type": "string" }, "filename": { "type": "string", "description": "URL slug. Renaming orphans any `seo_type=profile_search_results` web page bound to the OLD filename — that page can't query this category anymore and renders empty. Before renaming, run `listWebPages property=filename property_value=<old-filename>`; if a profile_search_results page exists, rename it in the same operation (and consider `createRedirect` for SEO continuity)." }, "desc": { "type": "string", "description": "Short internal taxonomy-row label. **Even if the user says \"description\" - this is NOT an SEO description.** Most BD themes don't render this field. For SEO copy on the Top-Category public search page (H1, intro, meta tags), create a WebPage with `seo_type=profile_search_results` + matching slug (see `createWebPage`). Short internal blurb only here." }, "keywords": { "type": "string", "description": "Fuzzy-search synonyms for on-site category matching - NOT SEO meta-keywords. Comma-separated single words (no spaces): synonyms, abbreviations, slang, common misspellings. Example for `Doctor`: `doc,physician,md,medic,gp,specialist`. ~5-10 max. Skip SEO phrases like `doctor near me` - those aren't fuzzy matchers. Optional." }, "icon": { "type": "string" }, "sort_order": { "type": "integer" }, "lead_price": { "type": "number" }, "image": { "type": "string" }, "_clear_fields": { "$ref": "#/components/schemas/_ClearFields" } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } } }, "description": "Update an existing TOP-level member category by `profession_id`. Fields omitted are untouched. Writes live data.\n\n**Use when:** renaming a category, changing its URL slug (`filename`), updating SEO keywords, or reordering. Changing `filename` breaks inbound links - also create a `Redirect` via `createRedirect` to preserve SEO.\n\n**Required:** `profession_id`.\n\n**Filename rename caveat:** if the existing `filename` has a `seo_type=profile_search_results` web page bound to it, renaming this category orphans that page. The wrapper rejects renames that would orphan a bound page — rename or delete the bound page first, then rename the category.\n\n**See also:** `createTopCategory` (add new), `deleteTopCategory` (remove).\n\n**Writes live data:** changes are immediately visible on the public site.\n\n**Returns:** `{ status: \"success\", message: {...updatedRecord} }`.\n\n\n\n**How a member gets classified on their public profile:**\n\n- `users_data.profession_id` -> points at a single Top Category (the member's primary classification; shown in URL slug)\n\n- `users_data.services` -> CSV of Sub Category IDs the member is tagged with (multiple allowed; simpler than the join table)\n\n- `rel_services` rows (Member ↔ Sub Category links) -> used when you need per-link metadata like `avg_price`, `specialty`, `num_completed`. Optional; most sites use just the CSV field.\n\n**Sub-sub-categories:** `createSubCategory` with `master_id=<parent service_id>` creates a Sub Category nested under another Sub Category (a \"sub-sub\"). `master_id=0` (default) means the Sub Category sits directly under a Top Category (the `profession_id`).\n\n**There is NO `createProfession` or `createService` tool in this MCP** — those are BD's internal table names. Use `createTopCategory` / `createSubCategory` instead (BD's table-name → tool-name mapping is documented in **Rule: Table to endpoint**).\n\n\n" } }, "/api/v2/list_professions/delete": { "delete": { "operationId": "deleteTopCategory", "summary": "Delete a category", "tags": [ "Categories" ], "requestBody": { "required": true, "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "required": [ "profession_id" ], "properties": { "profession_id": { "type": "integer", "description": "The top-level category ID to delete." } } } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } }, "description": "Permanently delete a TOP-level member category by `profession_id`. Destructive - cannot be undone via API.\n\n**Use when:** removing an unused top-level category. Any members with matching `profession_id` become orphaned - reassign them first. Any Sub Categories (`list_services` rows) under this top also orphan - delete or re-parent them.\n\n**Required:** `profession_id`.\n\n**Destructive:** confirm intent with the user. Members who referenced this `profession_id` will have orphan references. Sub Categories under this top (with matching `profession_id` in `list_services`) also become orphaned - consider reassigning or deleting them first.\n\n**Bound-page caveat:** if this category's `filename` has a `seo_type=profile_search_results` web page bound to it, deleting the category orphans that page (it'll render empty — no category to query). The wrapper rejects deletes that would orphan a bound page — delete or repurpose the bound page first.\n\n**See also:** `updateTopCategory` (modify without removing).\n\n**Returns:** `{ status: \"success\", message: \"list_professions record was deleted\" }`.\n\n\n\n**How a member gets classified on their public profile:**\n\n- `users_data.profession_id` -> points at a single Top Category (the member's primary classification; shown in URL slug)\n\n- `users_data.services` -> CSV of Sub Category IDs the member is tagged with (multiple allowed; simpler than the join table)\n\n- `rel_services` rows (Member ↔ Sub Category links) -> used when you need per-link metadata like `avg_price`, `specialty`, `num_completed`. Optional; most sites use just the CSV field.\n\n**Sub-sub-categories:** `createSubCategory` with `master_id=<parent service_id>` creates a Sub Category nested under another Sub Category (a \"sub-sub\"). `master_id=0` (default) means the Sub Category sits directly under a Top Category (the `profession_id`).\n\n**There is NO `createProfession` or `createService` tool in this MCP** — those are BD's internal table names. Use `createTopCategory` / `createSubCategory` instead (BD's table-name → tool-name mapping is documented in **Rule: Table to endpoint**).\n\n\n" } } }, "tags": [ { "name": "Authentication", "description": "API key verification" }, { "name": "Users", "description": "Members/users - create, update, search, login, transactions, subscriptions" }, { "name": "Reviews", "description": "User reviews and ratings" }, { "name": "Clicks", "description": "Link click tracking and analytics" }, { "name": "Leads", "description": "Lead capture, matching, and management" }, { "name": "Lead Matches", "description": "Lead-to-member matching records" }, { "name": "Posts", "description": "Single-image posts/content" }, { "name": "Portfolio Groups", "description": "Multi-image album groups" }, { "name": "Portfolio Photos", "description": "Individual photos within albums" }, { "name": "Post Types", "description": "Post type categories and their custom fields" }, { "name": "Unsubscribe", "description": "Email unsubscribe list management" }, { "name": "Widgets", "description": "Widget CRUD and rendering" }, { "name": "Email Templates", "description": "Email templates with merge tags and triggers" }, { "name": "Forms", "description": "Form definitions" }, { "name": "Form Fields", "description": "Individual form field configuration" }, { "name": "Membership Plans", "description": "Subscription plans and pricing" }, { "name": "Menus", "description": "Navigation menu containers" }, { "name": "Menu Items", "description": "Individual menu items within menus" }, { "name": "Categories", "description": "Categories (professions) - primary taxonomy" }, { "name": "Category Groups", "description": "Top-level groupings for categories" }, { "name": "Services", "description": "Sub-categories under categories" }, { "name": "User Services", "description": "Links between users and services they offer" }, { "name": "User Photos", "description": "Member profile photos (logo, photo, cover)" }, { "name": "User Metadata", "description": "Key-value metadata for any record" }, { "name": "Tags", "description": "Content tags" }, { "name": "Tag Groups", "description": "Tag group collections" }, { "name": "Tag Types", "description": "Tag type definitions and table relationships" }, { "name": "Tag Relationships", "description": "Polymorphic tag-to-object links" }, { "name": "Smart Lists", "description": "Dynamic filtered lists for members, leads, reviews, etc." }, { "name": "Pages", "description": "Static and SEO-enabled pages (homepage, about, landing pages, profile templates, etc.) - the list_seo resource" }, { "name": "Redirects", "description": "301 permanent redirect rules - preserve SEO when URLs change (profiles, posts, categories, custom rules)" }, { "name": "Website Settings", "description": "Site-level operations - cache management, configuration refresh. Undocumented by BD publicly but exposed in the admin API-permissions UI." }, { "name": "Data Types", "description": "Content-type templates available to members (e.g. single-photo, multi-photo, video, document). Each site configures its own set. Referenced by data_type field on posts and portfolio groups." } ] }