{
  "openapi": "3.1.0",
  "info": {
    "title": "pro.makeup public API",
    "version": "1.0.0",
    "summary": "Bearer-token-scoped access to the pro.makeup directory of certified makeup professionals.",
    "description": "Server-to-server endpoints for looking up, updating, and emitting analytics events against the pro.makeup business directory. See https://pro.makeup/.well-known/auth.md for how to request a token and https://pro.makeup/developers for human documentation.",
    "termsOfService": "https://pro.makeup/terms",
    "contact": {
      "name": "pro.makeup support",
      "email": "support@pro.makeup",
      "url": "https://pro.makeup/developers"
    },
    "license": {
      "name": "Proprietary",
      "identifier": "LicenseRef-pro-makeup-proprietary"
    }
  },
  "servers": [
    {
      "url": "https://pro.makeup",
      "description": "Production"
    }
  ],
  "tags": [
    { "name": "Search", "description": "Public, unauthenticated discovery across the directory" },
    { "name": "Business", "description": "Look up and update business profiles" },
    { "name": "Events", "description": "Emit signals on listings (analytics, freshness flags)" }
  ],
  "security": [{ "bearerAuth": [] }],
  "paths": {
    "/api/search/combined": {
      "get": {
        "tags": ["Search"],
        "summary": "Search the directory across all categories",
        "description": "Public, unauthenticated combined search across artists, salons, schools, shops, and brands. Supports free-text, category, location (country/city, or lat+lon+radius), and verification filters with pagination and per-category counts. This is the primary read surface for agents discovering listings; each result's canonical profile URL is `https://pro.makeup/{slug}`. The MCP tool `promakeup_search_pros` wraps this endpoint.",
        "operationId": "searchCombined",
        "security": [],
        "parameters": [
          { "name": "q", "in": "query", "required": false, "description": "Free-text query (matches name, services, specialties).", "schema": { "type": "string", "example": "bridal" } },
          { "name": "types", "in": "query", "required": false, "description": "Comma-separated category filter.", "schema": { "type": "string", "example": "artist,salon" } },
          { "name": "country", "in": "query", "required": false, "description": "Country slug.", "schema": { "type": "string", "example": "united-states" } },
          { "name": "city", "in": "query", "required": false, "description": "City slug.", "schema": { "type": "string", "example": "new-york" } },
          { "name": "verified", "in": "query", "required": false, "description": "When `true`, restrict to verified listings.", "schema": { "type": "boolean" } },
          { "name": "lat", "in": "query", "required": false, "description": "Latitude for radius search (pair with `lon`).", "schema": { "type": "number", "example": 40.7128 } },
          { "name": "lon", "in": "query", "required": false, "description": "Longitude for radius search (pair with `lat`).", "schema": { "type": "number", "example": -74.006 } },
          { "name": "radius", "in": "query", "required": false, "description": "Radius in kilometres around lat/lon.", "schema": { "type": "number", "example": 25 } },
          { "name": "sort", "in": "query", "required": false, "description": "Result ordering.", "schema": { "type": "string", "default": "distance" } },
          { "name": "page", "in": "query", "required": false, "schema": { "type": "integer", "default": 1, "minimum": 1 } },
          { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "default": 20 } }
        ],
        "responses": {
          "200": {
            "description": "Ranked results (may be empty).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/SearchResults" }
              }
            }
          },
          "500": { "$ref": "#/components/responses/Internal" }
        }
      }
    },
    "/api/business/by-slug/{slug}": {
      "get": {
        "tags": ["Business"],
        "summary": "Get a business by URL slug",
        "description": "Returns the same projection that the public profile page renders. The slug is the trailing segment of the canonical URL `https://pro.makeup/{category}/{country}/{city}/{slug}`.",
        "operationId": "getBusinessBySlug",
        "security": [{ "bearerAuth": ["listings:read"] }],
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "example": "israel/tel-aviv/jane-doe" }
          }
        ],
        "responses": {
          "200": {
            "description": "Found",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Business" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidInput" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Internal" }
        }
      }
    },
    "/api/business/by-phone": {
      "get": {
        "tags": ["Business"],
        "summary": "Reverse-lookup businesses by phone",
        "description": "Returns up to 5 active listings whose `phones[]` contains the given E.164 number. Used by the WhatsApp concierge to recognize a sender as the owner of one or more listings.",
        "operationId": "getBusinessByPhone",
        "security": [{ "bearerAuth": ["owner:read"] }],
        "parameters": [
          {
            "name": "phone",
            "in": "query",
            "required": true,
            "description": "E.164 phone (with leading `+`).",
            "schema": { "type": "string", "example": "+972501234567" }
          }
        ],
        "responses": {
          "200": {
            "description": "Found (may have zero results)",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/PhoneLookupResult" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidInput" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Internal" }
        }
      }
    },
    "/api/business/{id}/owner-update": {
      "patch": {
        "tags": ["Business"],
        "summary": "Owner-side partial update of a business",
        "description": "Token-gated PATCH for owner-driven mutations originating from the WhatsApp concierge. Authority is the BusinessOwnership table — the caller passes the owner's E.164 WhatsApp phone, we resolve it to an Account, and confirm BusinessOwnership(account_id, business_id) exists. Field whitelist is intentionally tight.",
        "operationId": "ownerUpdateBusiness",
        "security": [{ "bearerAuth": ["business:owner-update"] }],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "description": "Business id (nanoid)" }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/OwnerUpdateRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/OwnerUpdateResponse" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidInput" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Internal" }
        }
      }
    },
    "/api/business/{id}/flag-stale": {
      "post": {
        "tags": ["Events"],
        "summary": "Flag a listing's data as stale",
        "description": "Convenience wrapper around `POST /api/listing-events` for the common concierge write: \"this listing is wrong.\" Accepts only the staleness kinds (`bad_phone`, `closed_business`, `wrong_address`). Side effect: `bad_phone` clears `Business.last_phone_verified_at` so search ranking immediately reflects the stale signal.",
        "operationId": "flagStaleListing",
        "security": [{ "bearerAuth": ["listing-events:write"] }],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "description": "Business id (nanoid)" }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/FlagStaleRequest" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Event recorded",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ListingEventResponse" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidInput" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Internal" }
        }
      }
    },
    "/api/listing-events": {
      "post": {
        "tags": ["Events"],
        "summary": "Emit a listing analytics event",
        "description": "Append a single signal to the listing_events stream. Used to record interactions (`consumer_lead`, `profile_view_from_agent`, `bad_phone`, etc.) attributable to an external surface like the WhatsApp concierge or an MCP-driven agent. Full vocabulary is exported via the response body of a 400 (`details.allowed`).",
        "operationId": "emitListingEvent",
        "security": [{ "bearerAuth": ["listing-events:write"] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ListingEventRequest" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Event recorded",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ListingEventResponse" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidInput" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Internal" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "pmk_<id>_<secret>",
        "description": "Scoped Bearer tokens. See https://pro.makeup/.well-known/auth.md to request a token and learn the scope vocabulary."
      }
    },
    "headers": {
      "RateLimitLimit": {
        "description": "Per-scope, per-token request budget for the current window. Sized for personal / single-concierge use; verified search & AI crawlers route through a separate wide bucket and do not contend with these caps.",
        "schema": { "type": "integer", "example": 30 }
      },
      "RateLimitRemaining": {
        "description": "Requests remaining in the current window for this scope+token.",
        "schema": { "type": "integer", "example": 29 }
      },
      "RateLimitReset": {
        "description": "Epoch seconds when the current window resets.",
        "schema": { "type": "integer", "example": 1779890000 }
      }
    },
    "responses": {
      "InvalidInput": {
        "description": "Request body or parameters are malformed.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "examples": {
              "missingSlug": {
                "value": { "error": "Slug is required", "code": "INVALID_INPUT" }
              },
              "invalidJson": {
                "value": { "error": "Invalid JSON", "code": "INVALID_JSON" }
              }
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Bearer token is missing, malformed, expired, revoked, or unknown.",
        "headers": {
          "WWW-Authenticate": {
            "description": "RFC 6750 challenge; the `resource_metadata` parameter points at the RFC 9728 resource metadata document.",
            "schema": {
              "type": "string",
              "example": "Bearer realm=\"pro.makeup\", scope=\"listings:read\", resource_metadata=\"https://pro.makeup/.well-known/oauth-protected-resource\""
            }
          }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "examples": {
              "missingBearer": {
                "value": { "error": "Missing bearer token", "code": "BEARER_MISSING" }
              },
              "expiredBearer": {
                "value": { "error": "Token expired", "code": "BEARER_EXPIRED" }
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "Token is valid but does not carry the required scope, the caller has no authority to act on the resource, or the request was blocked by automated-scraping classification.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "examples": {
              "scopeMissing": {
                "value": { "error": "Missing scope: listings:read", "code": "SCOPE_MISSING" }
              },
              "scrapingBlocked": {
                "value": { "error": "Automated scraping clients are not permitted.", "code": "FORBIDDEN" }
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "The addressed resource does not exist.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "Listing not found", "code": "NOT_FOUND" }
          }
        }
      },
      "RateLimited": {
        "description": "Caller has exceeded the per-scope, per-token budget for the current window.",
        "headers": {
          "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" },
          "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" },
          "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" },
          "Retry-After": {
            "description": "Seconds until the bucket resets.",
            "schema": { "type": "integer", "example": 32 }
          }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "Rate limit exceeded for listings:read", "code": "RATE_LIMITED" }
          }
        }
      },
      "Internal": {
        "description": "Unexpected server error. Safe to retry with backoff.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "Failed to fetch listing", "code": "INTERNAL" }
          }
        }
      }
    },
    "schemas": {
      "SearchResults": {
        "type": "object",
        "description": "Envelope returned by GET /api/search/combined.",
        "required": ["results", "pagination", "counts"],
        "properties": {
          "results": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["id", "type", "name", "slug", "verified"],
              "properties": {
                "id": { "type": "string", "description": "Stable nanoid." },
                "type": { "type": "string", "enum": ["artist", "salon", "school", "shop", "brand", "discovery"] },
                "name": { "type": "string" },
                "slug": { "type": "string", "description": "Canonical profile URL is https://pro.makeup/{slug}.", "example": "israel/tel-aviv/jane-doe" },
                "verified": { "type": "boolean" },
                "location": {
                  "type": "object",
                  "additionalProperties": true,
                  "properties": {
                    "city": { "type": ["string", "null"] },
                    "country": { "type": ["string", "null"] }
                  }
                }
              },
              "additionalProperties": true
            }
          },
          "pagination": {
            "type": "object",
            "required": ["page", "limit", "total", "totalPages", "hasMore"],
            "properties": {
              "page": { "type": "integer" },
              "limit": { "type": "integer" },
              "total": { "type": "integer" },
              "totalPages": { "type": "integer" },
              "hasMore": { "type": "boolean" }
            }
          },
          "searchLocation": {
            "type": ["object", "null"],
            "description": "Echoed only when a lat/lon radius search was requested.",
            "properties": {
              "latitude": { "type": "number" },
              "longitude": { "type": "number" },
              "radius": { "type": ["number", "null"] }
            }
          },
          "counts": {
            "type": "object",
            "description": "Per-category result counts before pagination.",
            "properties": {
              "artists": { "type": "integer" },
              "salons": { "type": "integer" },
              "schools": { "type": "integer" },
              "shops": { "type": "integer" },
              "brands": { "type": "integer" },
              "discoveries": { "type": "integer" }
            }
          }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error", "code"],
        "description": "Stable error shape across all endpoints. `error` is human-readable; `code` is the machine-parseable identifier.",
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable summary of what went wrong."
          },
          "code": {
            "type": "string",
            "description": "Machine-readable error code. Full vocabulary documented at https://pro.makeup/developers.",
            "enum": [
              "BEARER_MISSING",
              "BEARER_INVALID",
              "BEARER_EXPIRED",
              "BEARER_REVOKED",
              "BEARER_BAD_FORMAT",
              "SCOPE_MISSING",
              "AUTH_UNAVAILABLE",
              "RATE_LIMITED",
              "FORBIDDEN",
              "INVALID_INPUT",
              "INVALID_JSON",
              "NOT_FOUND",
              "CONFLICT",
              "METHOD_NOT_ALLOWED",
              "INTERNAL",
              "UPSTREAM_UNAVAILABLE"
            ]
          },
          "details": {
            "type": "object",
            "description": "Optional structured context for the error (e.g. allowed values, the field that failed validation).",
            "additionalProperties": true
          }
        }
      },
      "Business": {
        "type": "object",
        "description": "Public projection of a business listing.",
        "required": ["id", "name", "slug", "primaryCategory", "categories", "isActive"],
        "properties": {
          "id": { "type": "string", "description": "Stable nanoid." },
          "name": { "type": "string" },
          "nameLocal": { "type": ["string", "null"], "description": "Original non-Latin-script name (Hebrew / Arabic / Cyrillic etc.)." },
          "slug": { "type": "string", "example": "israel/tel-aviv/jane-doe" },
          "primaryCategory": {
            "type": "string",
            "enum": ["artist", "salon", "school", "shop", "brand"]
          },
          "categories": {
            "type": "array",
            "items": { "type": "string", "enum": ["artist", "salon", "school", "shop", "brand"] }
          },
          "bio": { "type": ["string", "null"] },
          "location": {
            "type": "object",
            "properties": {
              "city": { "type": ["string", "null"] },
              "state": { "type": ["string", "null"] },
              "country": { "type": ["string", "null"] },
              "address": { "type": ["string", "null"] },
              "latitude": { "type": ["number", "null"] },
              "longitude": { "type": ["number", "null"] }
            }
          },
          "phones": { "type": "array", "items": { "type": "string", "description": "E.164" } },
          "email": { "type": ["string", "null"] },
          "website": { "type": ["string", "null"] },
          "social": {
            "type": "object",
            "properties": {
              "instagram": { "type": ["string", "null"] },
              "facebook": { "type": ["string", "null"] },
              "tiktok": { "type": ["string", "null"] },
              "youtube": { "type": ["string", "null"] },
              "linkedin": { "type": ["string", "null"] },
              "twitter": { "type": ["string", "null"] }
            }
          },
          "avatarUrl": { "type": ["string", "null"] },
          "photos": { "type": "array", "items": { "type": "string", "description": "Absolute URL on cdn.pro.makeup." } },
          "specialties": { "type": "array", "items": { "type": "string" } },
          "services": { "type": "array", "items": { "type": "string" } },
          "aggregateRating": {
            "type": "object",
            "properties": {
              "value": { "type": ["number", "null"] },
              "count": { "type": ["integer", "null"] }
            }
          },
          "openingHours": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Human-readable weekday descriptions from the Google Places business_hours payload."
          },
          "priceRange": { "type": ["string", "null"] },
          "isVerified": { "type": "boolean" },
          "isListed": { "type": "boolean" },
          "isActive": { "type": "boolean" },
          "privacySettings": {
            "type": "object",
            "additionalProperties": true,
            "description": "Owner-controlled visibility (e.g. `showPhone`, `showEmail`)."
          },
          "lastPhoneVerifiedAt": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "Freshness signal for the listing's phone number; null when never verified or flagged stale."
          }
        }
      },
      "PhoneLookupResult": {
        "type": "object",
        "required": ["phone", "count", "truncated", "listings"],
        "properties": {
          "phone": { "type": "string", "description": "Echo of the requested phone." },
          "count": { "type": "integer" },
          "truncated": { "type": "boolean", "description": "True when more than 5 listings match." },
          "listings": {
            "type": "array",
            "maxItems": 5,
            "items": {
              "type": "object",
              "required": ["id", "name"],
              "properties": {
                "id": { "type": "string" },
                "name": { "type": "string" },
                "primaryCategory": { "type": ["string", "null"], "enum": ["artist", "salon", "school", "shop", "brand", null] }
              }
            }
          }
        }
      },
      "OwnerUpdateRequest": {
        "type": "object",
        "required": ["ownerPhoneE164", "updates"],
        "properties": {
          "ownerPhoneE164": {
            "type": "string",
            "description": "The owner's WhatsApp phone in E.164 format (leading `+`). Authority anchor — must match a row in Account.phone.",
            "example": "+972501234567"
          },
          "updates": {
            "type": "object",
            "description": "Whitelisted owner-controlled fields. Allowed keys today: `bio`, `business_hours`, `services`, `email`, `website`, `whatsapp`.",
            "additionalProperties": true,
            "minProperties": 1
          }
        }
      },
      "OwnerUpdateResponse": {
        "type": "object",
        "required": ["success", "businessId", "updatedFields"],
        "properties": {
          "success": { "type": "boolean" },
          "businessId": { "type": "string" },
          "name": { "type": "string" },
          "slug": { "type": "string" },
          "updatedFields": {
            "type": "array",
            "items": { "type": "string" }
          },
          "current": {
            "type": "object",
            "description": "Post-image of the fields that were touched, so the caller can confirm what landed.",
            "additionalProperties": true
          }
        }
      },
      "ListingEventRequest": {
        "type": "object",
        "required": ["businessId", "kind", "source"],
        "properties": {
          "businessId": { "type": "string", "description": "Target Business.id (nanoid)." },
          "kind": {
            "type": "string",
            "description": "Event kind. Full vocabulary returned in `details.allowed` on a 400."
          },
          "source": {
            "type": "string",
            "description": "Free-form caller identifier (e.g. `wa_chat`, `agent:claude`, `mcp`)."
          },
          "meta": {
            "type": ["object", "null"],
            "additionalProperties": true,
            "description": "Optional structured payload preserved verbatim."
          }
        }
      },
      "FlagStaleRequest": {
        "type": "object",
        "required": ["kind"],
        "properties": {
          "kind": {
            "type": "string",
            "enum": ["bad_phone", "closed_business", "wrong_address"]
          },
          "source": {
            "type": "string",
            "default": "wa_chat"
          },
          "meta": { "type": ["object", "null"], "additionalProperties": true }
        }
      },
      "ListingEventResponse": {
        "type": "object",
        "required": ["id", "kind", "businessId", "createdAt"],
        "properties": {
          "id": { "type": "string", "description": "The new listing_events row id." },
          "kind": { "type": "string" },
          "businessId": { "type": "string" },
          "createdAt": { "type": "string", "format": "date-time" },
          "phoneFreshnessCleared": {
            "type": "boolean",
            "description": "Only present on flag-stale `bad_phone`. True when Business.last_phone_verified_at was successfully cleared."
          }
        }
      }
    }
  },
  "externalDocs": {
    "url": "https://pro.makeup/developers",
    "description": "Human-readable developer portal"
  }
}
