openapi: 3.2.0 info: title: EpicDirectories Public API description: API-first directory data platform. version: '1.0' contact: {} servers: - url: https://api.epicdirectories.com description: Production - url: http://localhost:3333 description: Local development tags: - name: Public paths: /public/v1/directories/{directory}: get: operationId: PublicApiController_getDirectory summary: Get directory configuration and schema parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string responses: '200': description: Directory details with taxonomies and attribute templates '401': description: Missing or invalid X-API-Key '404': description: Directory not found or out of API key scope tags: - Public security: - apiKey: [] patch: operationId: PublicApiController_updateDirectory summary: Update directory configuration description: 'Requires `write`. Covers `name`, `listingTypes` and the four `submissionPolicy` switches. Added because `listingTypes` was settable only through the Clerk admin surface, so an integration that could provision its own taxonomies, attribute templates and listings still had to stop and ask a human to declare the listing type those listings would use. `submissionPolicy` joined it on 2026-10-01 for the same reason: a headless consumer could not open page comments or close reviews on its own directory. PATCH semantics — an absent field is left alone. The two object fields differ, so read this twice: `listingTypes` REPLACES the whole map, so send every type you want to keep, whereas `submissionPolicy` is MERGED, so an absent switch keeps its value and undeclared keys such as `rejectionReasons` survive. `slug`, `domain` and the other `*Policy` fields are deliberately NOT settable here; see UpdatePublicDirectoryDto for why.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UpdatePublicDirectoryDto' responses: '200': description: Directory updated '400': description: Malformed listingTypes '403': description: Key lacks the `write` scope '409': description: Removing a listing type that still has listings. Body lists each type and its count. tags: - Public security: - apiKey: [] /public/v1/me: get: operationId: PublicApiController_describeKey summary: Describe this API key description: 'What the key can reach: its account, its scopes, and the directories in range. `scopes: null` means **unrestricted** (a legacy key issued before scopes existed) and is reported verbatim rather than flattened into a list. Added because there was previously no way to discover either: callers guessed directory slugs until one returned 200, and probed a deliberately-missing term to tell 403 from 404.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string responses: '200': description: Account, scopes and reachable directories '401': description: Missing or invalid X-API-Key tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/taxonomies: post: operationId: PublicApiController_createTaxonomy summary: Create a taxonomy description: Requires `write`. Returns 409 with the existing record if the slug is taken, so the caller can switch to using it instead of round-tripping to find out. parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateTaxonomyDto' responses: '201': description: Taxonomy created '403': description: Key lacks the `write` scope '409': description: Slug already used in this directory tags: - Public security: - apiKey: [] get: operationId: PublicApiController_listTaxonomies summary: List this directory's taxonomies description: 'No scope required — taxonomies are schema, and `GET /export` already hands every one of them to any key on the directory. Returns each taxonomy with a `termCount`, not its terms. `GET .../taxonomies/:taxonomy/terms` serves those and paginates; inlining them would make the cost of listing four taxonomies depend on the size of the largest, which on a directory with a 9,000-term geography taxonomy is the difference between an index and a timeout.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string responses: '200': description: Taxonomies, ordered by slug tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/taxonomies/{taxonomy}/terms: post: operationId: PublicApiController_createTerm summary: Create a taxonomy term description: 'Requires `write`. **This completes the `resolve-batch` loop** — the docs there describe looping `unmapped` values to create terms or extend synonyms, which was previously impossible with a public key because term creation was admin-only. **Nest with `parentSlug`.** `parentTermId` is also accepted for the keys already using it, but no public route returns a term id, so a slug is the only parent reference a caller can actually obtain from this surface. Sending both is a 400. Either way the parent must belong to the same taxonomy. Note this route does NOT require the taxonomy to be `hierarchical`, while PATCH does — an asymmetry that predates `parentSlug` and is left as-is rather than tightened under a key that may be relying on it. `metadata` carries the conventional `synonyms` array that `resolve` matches against.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: taxonomy required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PublicCreateTermDto' responses: '201': description: Term created '400': description: Parent not found in this taxonomy, or both `parentSlug` and `parentTermId` sent '403': description: Key lacks the `write` scope '409': description: Term slug already used in this taxonomy tags: - Public security: - apiKey: [] get: operationId: PublicApiController_listTaxonomyTerms summary: List a taxonomy's terms, with a listing count each description: 'The read half of the term API, which was missing. This surface has had term POST, PATCH and DELETE since taxonomy writes shipped, and no GET of any kind — so a term could be created and updated but never listed, and the only way to see what a taxonomy contained was to pull the whole directory root. Convention Grid hit exactly that reworking 66 industry terms. **`listingCount` respects your key.** A key without `read:drafts` gets the count of listings it can actually fetch — published, plus drafts whose `publishAt` has passed — and `listingCountIncludesDrafts` says which you were given. This matters more than it sounds: CourseAlmanac holds 14,470 listings and one published, so an unscoped count would render "Golf Courses (14,470)" above a list of one. **`parentSlug`, not `parentTermId`.** Slugs are what every other identifier on this surface is, and needing the parent''s UUID is why nesting a term currently takes a second call. `childSlugs` is the same hierarchy from the other side, so a tree can be built without a second pass. **Not paginated.** A taxonomy is an operator-curated set: the largest on the platform holds 141 terms and all of them together hold 614. The directory root already returns every term in one payload. **To list the listings tagged with a term, use `GET /directories/{directory}/listings?terms={slug}`** — that already exists and takes comma-separated slugs (matching ANY of them).' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: taxonomy required: true in: path schema: type: string responses: '200': description: The taxonomy and all of its terms '401': description: Missing or invalid X-API-Key '404': description: Directory or taxonomy not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/turnstile: get: operationId: PublicApiController_getTurnstile summary: Which Turnstile widget verifies this directory's public forms description: 'Reports the credentials IN EFFECT, which may be the shared platform widget rather than this directory''s own. `configured: false` with `source: "platform"` is the state worth acting on: that widget issues tokens only on hostnames registered against it, and on any other host the anonymous write endpoints accept every submission and record it as `spam` behind the same 202 they always return. Nothing fails visibly. Needs no scope beyond a valid key — the site key is already published in `GET /export` and ships in the HTML of every page carrying the widget. No part of the secret is returned here, unlike the admin route: this surface cannot rotate one, so it has no reason to confirm a rotation.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string responses: '200': description: Current Turnstile configuration '401': description: Missing or invalid X-API-Key '404': description: Directory not found or out of API key scope tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/turnstile/provision: post: operationId: PublicApiController_provisionTurnstile summary: Create or reconcile this directory's own Turnstile widget description: 'Requires `write`. Gives the directory a widget of its own on the EpicDirectories Cloudflare account, registered against the `domain` recorded on the directory plus that domain''s `www` alias, and starts serving its site key from `GET /export`. Your next build renders a key that works on your hostname instead of the platform one, which does not. Normally nothing needs to call this: an operator recording the `domain` provisions it. It is here for the cases that need a nudge — a directory whose domain predates provisioning, or one whose hostname list was edited in the Cloudflare dashboard and has drifted. It always re-sends the hostname list rather than trusting our own record of it. Idempotent, and safe to put in a deploy pipeline: a widget already covering the right hostnames is `unchanged` and costs no Cloudflare call. It refuses to touch a widget registered by hand (`skipped-manual`), and a directory with no `domain` recorded comes back `skipped-no-domain` — ask an operator to record one, as that field is not settable with a key. The new widget does NOT reach your visitors until you rebuild. Until then the API verifies against the new secret while your pages still render the old site key, and submissions in between are recorded as spam — so rebuild straight after calling this.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string responses: '200': description: What was done, plus the same shape as GET '401': description: Missing or invalid X-API-Key '403': description: Key lacks the `write` scope '404': description: Directory not found or out of API key scope '503': description: Cloudflare could not be used, and nothing was changed. The message is the one to act on; it is usually ours to fix, not yours. tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/attribute-templates: post: operationId: PublicApiController_createAttributeTemplate summary: Create an attribute template description: 'Requires `write`. Without this, `PUT .../attributes` rejects every key as unknown, so a public-key integration could never use typed, filterable or sortable attributes and was forced to put everything in free-form `canonicalFields` — where recipes cannot scope on it. Uniqueness is per `(directory, listingType, key)`. A null `listingType` means "applies to every type" and can coexist with a same-key type-scoped template, matching how attribute resolution works on write.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateAttributeTemplateDto' responses: '201': description: Attribute template created '403': description: Key lacks the `write` scope '409': description: Key already defined for this listingType tags: - Public security: - apiKey: [] get: operationId: PublicApiController_listAttributeTemplates summary: List attribute templates description: 'No scope required — templates are schema, and `GET /export` already hands every one of them to any key on the directory. Gating the dedicated read while the bulk read is open would protect nothing and cost a caller a 40 MB export to answer "what fields exist". `listingType` filters the way attribute RESOLUTION works, not the way the column reads: it returns the type-scoped templates **plus** the type-agnostic ones, which together are exactly the keys `PUT .../attributes` will accept on a listing of that type.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: false in: query description: Templates applicable to this listing type, including type-agnostic ones. schema: type: string responses: '200': description: Templates, ordered by listingType then key tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/attribute-templates/{template}: get: operationId: PublicApiController_getAttributeTemplate summary: Get one attribute template description: 'No scope required, for the reason the list route gives. `:template` is **either** the template id **or** its `key`. The key form is the one worth using: a consumer that defined `square_footage` knows that name, and should not have to store our uuids to address it again — the same reasoning that makes every listing path here `(listingType, slug)`. When you address by key, `?listingType=` picks the scope. **Omitting it means the type-agnostic template specifically, not "whichever one exists".** One key can hold two templates — one scoped to a listing type, one applying to all of them — because the uniqueness constraint has a nullable column in it. A 404 here tells you if the key exists under another scope.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: template required: true in: path schema: type: string - name: listingType required: false in: query description: Scope to address, when using a key. schema: type: string responses: '200': description: The template '404': description: No such template in this scope; the body names where the key does exist tags: - Public security: - apiKey: [] patch: operationId: PublicApiController_updateAttributeTemplate summary: Update an attribute template description: 'Requires `write`. Addressed by id or `key` exactly as the GET above, `?listingType=` and all. `key` and `listingType` are **not** settable. Both are part of the identity every stored value was written against and every consumer build reads by name, so moving either is a rename — and renames on this platform get aliases or they get refused. Delete and recreate if that is really the intent. Changing `type` is allowed and does **not** rewrite stored values: a `number` turned `enum` leaves numbers sitting behind a vocabulary that does not list them. The admin UI has always allowed this and no read path coerces, so this is not narrower than the UI — but it is worth knowing before doing it to 30,000 listings. An empty body is a no-op: it returns the template unchanged and fires nothing, rather than bumping `updatedAt` and rebuilding a consumer site for a request that changed nothing.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: template required: true in: path schema: type: string - name: listingType required: false in: query description: Scope to address, when using a key. schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UpdateAttributeTemplateDto' responses: '200': description: The updated template '403': description: Key lacks the `write` scope '404': description: No such template in this scope tags: - Public security: - apiKey: [] delete: operationId: PublicApiController_deleteAttributeTemplate summary: Delete an attribute template description: 'Requires `write`. Addressed by id or `key`, with `?listingType=` as above. **This deletes every value any listing holds for the template.** The foreign key cascades, so one DELETE on a 31,000-listing directory can destroy 31,000 rows, and nothing restores them — `/export` is the only copy and it is yours, not ours. So a template that still has values is refused with **409 and the count**; re-send with `?force=true` to confirm the number was expected. A template with no values deletes with no flag, which is the usual case — removing seeded fields a directory never populated needs no ceremony, and a confirmation flag you always send protects nobody. The response reports `valuesDeleted`, so a caller who forced it learns the cost rather than reconstructing it from a diff of two exports.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: template required: true in: path schema: type: string - name: listingType required: false in: query description: Scope to address, when using a key. schema: type: string - name: force required: false in: query description: Set `true` to delete a template that still holds values, and those values with it. schema: type: string responses: '200': description: Deleted; body reports how many values went with it '403': description: Key lacks the `write` scope '404': description: No such template in this scope '409': description: Template still holds values and `force` was not set tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listings/{listingType}/{listingSlug}: delete: operationId: PublicApiController_archiveListing summary: Archive a listing (soft delete) description: 'Requires `write`. Sets `status: archived`, which removes the listing from every public read — those already gate on published-or-scheduled — while **retaining snapshots, attributes, terms and media**. Deliberately not a hard delete: every child of Listing cascades, `SourceSnapshot` included, so a real DELETE would destroy the evidence locker. Restore by PATCHing `status` back. **The slug stays reserved** — reversibility and slug reuse are mutually exclusive, and reversibility is the point. Use this to clean up smoke tests without leaving permanent clutter.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string responses: '200': description: Archived; restorable '403': description: Key lacks the `write` scope '404': description: Listing not found tags: - Public security: - apiKey: [] get: operationId: PublicApiController_getListingByTypeSlug summary: Get a listing unambiguously by type + slug description: For ingestion agents checking existence before write. Read-only keys without read:drafts see only published listings here; keys holding write or read:drafts see any status. parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string responses: '200': description: Listing found '404': description: Listing not found (or visible only with read:drafts) tags: - Public security: - apiKey: [] patch: operationId: PublicApiController_updateListing summary: Update a listing by type + slug (including renaming its slug) description: 'Slug is the idempotency key — agents should use deterministic slugs and rely on POST→409→PATCH or check existence with the corresponding GET first. **JSON columns are full-replace.** `editorialFields`, `canonicalFields`, and `geo` each replace the entire stored object when present in the body. Sending `{ editorialFields: { seoTitle: "X" } }` wipes description and every other editorial key. To partially update, GET the listing first, spread the existing object, then PATCH with the merged result. The `taxonomies` field is the exception — it does per-taxonomy replace (taxonomies you don''t list are untouched). **Renaming.** Send `slug` to move a listing. The old address is kept as an alias, so every read that used it — this endpoint, the untyped GET, `/listings/batch`, and the media and snapshot routes hanging off it — keeps resolving, and answers with the NEW slug. The response echoes `previousSlug`, and `listing.updated` carries it too, so a consumer can publish a 301 without re-reading anything. `GET .../listing-aliases` returns the whole redirect map. `listingType` remains immutable — it is half a listing''s identity, and moving a row between type-scoped namespaces would invalidate its parent links and attribute templates.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UpdatePublicListingDto' responses: '200': description: Listing updated. Includes `previousSlug` when the slug moved. '404': description: Listing not found '409': description: The requested slug is taken — either by a live listing, or reserved as another listing's former address. The body names the holder. tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/export: get: operationId: PublicApiController_exportDirectory summary: Full-directory export for static-site builds description: 'Everything a build needs in one call: directory config + listingTypes, attribute templates, taxonomies with all terms (including `metadata.synonyms`), recipes, and listings with their attributes, terms, and **all** ready media (the browse endpoints cap media at 5 per listing). **Keyset pagination, not offset.** Loop until `pagination.nextCursor` is null, passing it back as `?cursor=`. Offset pagination is wrong here: an export can run for many seconds while writes land, and a shifting ordering key would silently skip or duplicate rows. Ordered by `id` ascending. Default page size 500, max 1000 — larger than the 100 cap on browse endpoints because this is a build-time bulk pull. The fixed sections (directory, schema, taxonomies, recipes) repeat on every page so each page is independently usable; pass `listings=false` to fetch only those, e.g. to generate routes before pulling content. Each media row carries `source` + `sourceRef`, the same provenance pair `GET .../media` returns, so a build can join our delivery copies back to its own catalogue in one bulk pull. `sourceRef` holds the external URL the asset was created from, **except** when `source` is `upload` or `screenshot` — those two are rewritten with an internal R2 object key once the bytes are stored, and only those two. Treat it as a denylist, not an allowlist: `source` is free-form (see POST .../media), so callers set their own values like `official_site`, and an allowlist of known sources silently skips them. Do not sniff whether the value looks like a URL either — branch on `source`. Each listing carries `websiteUrl`: its own site, resolved server-side, or `null`. Prefer it over reading `canonicalFields` yourself — the value lives under one of several keys (`website`, `url`, `homepage`, `organizerWebsite`) and a `series_of` child inherits its series'', which you cannot reproduce from this response because it carries `parentSlug` rather than the parent''s fields. It is also the URL our probes fetched, so a `site-dead` screening term is a verdict about this link and no other. Unpublished listings require the `read:drafts` scope, same as every other read.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: limit required: false in: query description: Listings per page (default 500, max 1000) schema: type: number - name: cursor required: false in: query description: Listing id from a previous response's `pagination.nextCursor` schema: type: string - name: listings required: false in: query description: Set to `false` to omit listings and return only config/schema/taxonomies/recipes schema: type: string - name: fields required: false in: query description: Comma-separated dotted paths to project on each listing (e.g. `slug,title,geo.city`). Applies to listings only — the fixed schema sections come back whole. schema: type: string responses: '200': description: Directory export page '401': description: Missing or invalid X-API-Key '404': description: Directory not found or out of API key scope tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listings: get: operationId: PublicApiController_getListings summary: List published listings parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: false in: query schema: type: string - name: status required: false in: query description: Filter by lifecycle status. `published` also matches drafts whose `publishAt` has passed, matching how every other read path treats scheduled publishing. Any other value requires the `read:drafts` scope and is matched exactly. Omit to get everything your key may see. schema: type: string - name: limit required: false in: query description: Page size, capped at 100. A larger value is clamped, and `pagination.limit` reports the limit APPLIED — so `offset += pagination.limit` is always correct. It previously echoed the requested value, which made that loop skip every other page. schema: type: number - name: offset required: false in: query schema: type: number - name: terms required: false in: query description: Comma-separated taxonomy term slugs schema: type: string - name: fields required: false in: query description: Comma-separated dotted paths to project (e.g. `slug,title,canonicalFields.address`). When set, response items contain only the requested paths — saves bandwidth on large payloads. schema: type: string - name: q required: false in: query description: Free-text search. Case-insensitive substring match against **title, slug, or the listing's URL** — after an import the slug is often the only handle you have, and every site-probe report identifies a listing by host. A URL query is folded to a hostname (scheme and `www.` stripped) before matching. schema: type: string - name: city required: false in: query description: Exact match against `geo.city`. Combines with the other place filters as AND. schema: type: string - name: admin1 required: false in: query description: Exact match against `geo.admin1` — state or equivalent, e.g. `TX`. Combines as AND. schema: type: string - name: country required: false in: query description: Exact match against `geo.country`. NOT normalised across directories — some store `US`, others `United States` — so filter with the value your own data uses. schema: type: string - name: postalCode required: false in: query description: Exact match against `geo.postalCode`. Combines as AND. schema: type: string - name: featured required: false in: query description: '`true` returns only premium (boosted) listings, `false` only unboosted ones. Omit for both — the default ordering is unchanged either way, so a boost never silently reorders an existing consumer''s results.' schema: type: string responses: '200': description: Paginated list of listings '400': description: Unknown query parameter. Rejected rather than ignored — a silently dropped filter returns data that looks correctly scoped and is not. The body lists `unknownParams` and `allowedParams`. '401': description: Missing or invalid X-API-Key '403': description: status filter other than `published` without `read:drafts` '404': description: Directory not found or out of API key scope tags: - Public security: - apiKey: [] post: operationId: PublicApiController_createListing summary: Create a listing in this directory parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreatePublicListingDto' responses: '201': description: Listing created '409': description: A listing with this listingType + slug already exists. Response includes the existing listing summary so the caller can switch to PATCH. tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listings/bulk: get: operationId: PublicApiController_getListingsBulk summary: Bulk fetch all published listings for SSG / static builds description: Returns up to 5000 listings in a single response, no pagination. Use ?listingType to filter, ?since=ISO_DATE to fetch only records updated after that timestamp (incremental builds). For UI/runtime use the paginated /listings endpoint instead. parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: false in: query schema: type: string - name: since required: false in: query description: ISO 8601 datetime — only records with updatedAt >= since schema: type: string - name: cap required: false in: query description: Max records (default + cap = 5000) schema: type: number - name: fields required: false in: query description: Comma-separated dotted paths to project (e.g. `slug,title,canonicalFields.address`). When set, response items contain only the requested paths — typically cuts payload size by 80%+ on bulk fetches. schema: type: string responses: '200': description: All matching listings, full transform shape '401': description: Missing or invalid X-API-Key '404': description: Directory not found or out of API key scope tags: - Public security: - apiKey: [] post: operationId: PublicApiController_bulkUpsertListings summary: Bulk upsert listings by (listingType, slug) description: 'Requires `write`. Up to 500 items per request, upserted on the `(listingType, slug)` unique key — so re-sending the same batch is idempotent. **Not to be confused with `POST .../listings/batch`, which is a batch READ.** That naming has cost integrators time; this is the write. `attributes` may be sent inline here, unlike on the single-listing create. On the single path `PUT .../attributes` is one extra call; in bulk it is the difference between 2N and N round trips, which is the point of the endpoint. **Partial writes are the default.** Every other write in this API refuses them; this one allows them because at 500 items, all-or-nothing means one bad row rejects 499 good ones and you cannot tell which without bisecting. Each item reports `created`, `updated` or `failed` with its own `error`. Pass `atomic: true` to stop at the first failure — note that items already written are NOT rolled back, and the response says how many. One webhook fires for the whole request per event kind, carrying `{ bulk: true, count, listingIds }` with ids capped at 100, rather than one per listing.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BulkUpsertListingsDto' responses: '200': description: Per-item results plus a summary '400': description: 'More than 500 items, or a failure with atomic: true' '403': description: Key lacks the `write` scope '404': description: Directory not found or out of API key scope tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listings/{listing}: get: operationId: PublicApiController_getListing summary: Get a single listing by slug or ID parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listing required: true in: path schema: type: string - name: fields required: false in: query description: Comma-separated dotted paths to project (e.g. `slug,title`). schema: type: string responses: '200': description: Full listing details (with nested children when applicable) '401': description: Missing or invalid X-API-Key '404': description: Listing or directory not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/recipes: get: operationId: PublicApiController_listRecipes summary: List recipe definitions description: 'No scope required — recipes are schema, and `GET /export` already carries every definition to any key on the directory. Full definitions, not summaries: a recipe is four small JSON objects, and the caller most likely to list them is one about to PATCH one and needs `scope` in hand to merge into. Ordered by slug rather than newest-first, so two consecutive builds are comparable. Note this is the **definitions**, not their results. `GET .../recipes/:recipe` executes.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: recipeType required: false in: query description: Only recipes of this type. schema: type: string responses: '200': description: Recipe definitions, ordered by slug tags: - Public security: - apiKey: [] post: operationId: PublicApiController_createRecipe summary: Create a recipe definition description: 'Requires `write`. Until this existed, a key could EXECUTE a recipe and not write one, so every collection page a consumer wanted had to be added by a human in the admin UI first — on the one object in the model that is pure configuration and holds no content at all. Slug is unique per directory; a clash returns **409 carrying the existing recipe**, so you can switch to PATCHing it without a round trip to find out which it was.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateRecipeDto' responses: '201': description: Recipe created '403': description: Key lacks the `write` scope '409': description: Slug already used in this directory tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/recipes/{recipe}/definition: get: operationId: PublicApiController_getRecipeDefinition summary: Read one recipe definition description: 'No scope required, for the reason the list route gives. Addressed by slug or id. **Why `/definition` and not just `GET .../recipes/:recipe`.** That route already exists and it EXECUTES the recipe — it has since early on, and consumers call it as the executor today. Note this is inverted from `/v1`, where `GET :recipeId` is the definition and `:recipeId/preview` runs it. That asymmetry is not an improvement, it is just what shipped; a route whose meaning changes under a caller is worse than a route with an unlovely name.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: recipe required: true in: path schema: type: string responses: '200': description: The recipe definition '404': description: No such recipe in this directory tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/recipes/{recipe}: patch: operationId: PublicApiController_updateRecipe summary: Update a recipe definition description: 'Requires `write`. Addressed by slug or id. **Every object field REPLACES, it does not merge** — the opposite of `submissionPolicy` on the directory PATCH, and worth knowing which is which. That one merges because the DTO cannot express every key the column holds, so a replace would silently drop `rejectionReasons`. Here the DTO *is* the whole shape: `scope`, `constraints`, `ranking` and `seo` are your own blobs with no keys we define, so a replace can destroy nothing you did not just choose to omit — and a merge would make removing a filter from a scope impossible to express. `slug` **is** settable, unlike the directory and taxonomy slugs, because a recipe slug is not an address anything else keys on: no API key is pinned to it and no stored row references it. It is one of your own page names, so renaming it 404s that page until you rebuild. An empty body is a no-op and fires nothing.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: recipe required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UpdateRecipeDto' responses: '200': description: The updated definition '403': description: Key lacks the `write` scope '404': description: No such recipe in this directory '409': description: The new slug is already used by another recipe here tags: - Public security: - apiKey: [] delete: operationId: PublicApiController_deleteRecipe summary: Delete a recipe definition description: 'Requires `write`. Addressed by slug or id. The one delete on this surface with **no confirmation flag and no count**, because nothing references a Recipe: it owns no child rows and no listing points at it. A recipe is a stored QUERY, so deleting one destroys the query and not a single row of content, and re-POSTing the same definition restores it exactly — which is not true of an attribute template or a taxonomy. It will still take a page off your site at the next build. That is worth knowing and is not worth a flag.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: recipe required: true in: path schema: type: string responses: '200': description: Deleted '403': description: Key lacks the `write` scope '404': description: No such recipe in this directory tags: - Public security: - apiKey: [] get: operationId: PublicApiController_executeRecipe summary: Execute a recipe and get matching listings parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: recipe required: true in: path schema: type: string - name: fields required: false in: query description: Comma-separated dotted paths to project on each listing in the result. schema: type: string responses: '200': description: Recipe result with matching listings '401': description: Missing or invalid X-API-Key '404': description: Recipe or directory not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/jobs: get: operationId: PublicApiController_listJobs summary: List this directory's jobs description: 'Requires `read:drafts` or `write`. A job row carries `inputJson` and `errorJson`, which describe work on unpublished content, so this is gated with the same pair as the dead-link report — a monitoring cron should not need a key that can delete every listing just to poll progress. **Jobs enqueued through `POST .../jobs` appear here. Nothing else does.** `JobsService` is the only writer of a job row in the platform, and the sweep routes — `POST .../screenshots`, `POST .../research`, `POST .../site-probes/sweep`, `POST .../enrich` — enqueue into the queue directly and create no row. So if you start a sweep and poll here you will see an empty list, and the sweep is fine. Use each sweep''s own coverage endpoint until those routes are wired to create a job row; this note goes away when they are. Newest first, with `id` as a tiebreak: one import fans out hundreds of jobs inside the same millisecond, so without the second sort key two pages of the same list can repeat and skip rows.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: jobType required: false in: query schema: type: string - name: status required: false in: query description: pending, queued, processing, completed, failed or cancelled. schema: type: string - name: limit required: false in: query description: Default 50, max 200. schema: type: string - name: offset required: false in: query schema: type: string responses: '200': description: Jobs, newest first '403': description: Key lacks `read:drafts` and `write` tags: - Public security: - apiKey: [] post: operationId: PublicApiController_createJob summary: Enqueue a job description: 'Requires `write`. Enqueues `import` or `enrichment`. **The other three job types are refused with a 400 that names the route to use instead**, and not for security reasons — a key with `write` can already do more than any of them. Each would be a button that does nothing: - `recipe_build` / `recipe_rebuild_all` — recipe execution is not implemented. The processor used to sleep three seconds and mark the job `completed` with `pagesGenerated: 0`; that pretence was removed and it now refuses, so enqueueing one from here would reintroduce the same lie one layer up. `GET .../recipes/{recipe}` executes a recipe and returns the matches. - `media_process` — the media processor never updates its job row, so the row would sit at `queued` for ever whatever the work did. `POST .../media/{assetId}/reprocess` does this properly. Queue routing and retry policy are shared with the admin surface rather than reimplemented, so the two cannot drift on which queue a type lands in.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreatePublicJobDto' responses: '201': description: Job created and queued '400': description: Job type not enqueueable from this surface; the body names the route that is '403': description: Key lacks the `write` scope tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/jobs/{jobId}: get: operationId: PublicApiController_getJob summary: Get one job description: 'Requires `read:drafts` or `write`, as the list route does. Returns the stored row: `status`, `inputJson`, `resultJson` and `errorJson`. `errorJson` is written by the processor from a caught exception, so treat it as text you did not author — nothing in it is cross-tenant, but it is not a field to render into a page unescaped. Deliberately **not** the admin shape, which also reaches into BullMQ for live queue state (`progress`, `attemptsMade`, `failedReason`). That read needs the Redis connection and fails open to `null` when Redis is unreachable, which on this surface would make a job look un-started during an incident rather than un-pollable. The stored row is written by the processor and is the thing that survives.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: jobId required: true in: path schema: type: string responses: '200': description: The job '403': description: Key lacks `read:drafts` and `write` '404': description: No such job in this directory tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/jobs/{jobId}/cancel: post: operationId: PublicApiController_cancelJob summary: Cancel a job description: 'Requires `write`. Removes the job from its queue and marks the row `cancelled`. Idempotent: a job that has already finished returns 200 reporting its final status rather than an error, because the caller''s intent — "do not run this" — is satisfied either way, and a retried cancel is the most likely second call there is. **Cancelling does not undo work already done.** A cancelled import keeps the listings it had already written; `status` goes to `cancelled` and the rows stay. Nothing rolls back, because an import is thousands of independent upserts rather than one transaction.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: jobId required: true in: path schema: type: string responses: '200': description: Cancelled, or already finished '403': description: Key lacks the `write` scope '404': description: No such job in this directory tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/jobs/{jobId}/retry: post: operationId: PublicApiController_retryJob summary: Retry a failed or cancelled job description: 'Requires `write`. Creates a **new** job with the same `inputJson` and returns it — the original row is left as it is, so a failure stays on the record rather than being overwritten by its own retry. Poll the id in the response, not the one you sent. Only `failed` and `cancelled` jobs retry; anything else returns 200 saying so rather than erroring, matching cancel. The retry goes through the same job-type whitelist as `POST .../jobs`, so a `recipe_build` row created before that whitelist existed cannot be revived through this route.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: jobId required: true in: path schema: type: string responses: '200': description: A new job, or a note that the original is not retriable '400': description: The original job's type is not enqueueable from this surface '403': description: Key lacks the `write` scope '404': description: No such job in this directory tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/taxonomies/{taxonomy}/terms/{term}: get: operationId: PublicApiController_getTaxonomyTerm summary: Read one term by slug description: 'Same term shape as the list route, addressed by slug. Use it to read a term back before a PATCH: `metadata` is full-replace, so the correct partial update is to GET this, spread `metadata`, add your fields and send the merged object. Until this route existed there was no way to perform that read, which made every partial metadata update a guess.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: taxonomy required: true in: path schema: type: string - name: term required: true in: path schema: type: string responses: '200': description: The term '401': description: Missing or invalid X-API-Key '404': description: Directory, taxonomy, or term not found tags: - Public security: - apiKey: [] patch: operationId: PublicApiController_updateTerm summary: Update a term's label / parent / metadata description: 'Edit term-level metadata (synonyms, seoKeywords, relatedTopics, description, etc.) without round-tripping through the admin UI. Body matches the admin UpdateTermDto shape — see /v1/.../taxonomies/:tax/terms/:term for the full field list. **The slug is immutable.** There is no `slug` field on the body and there is no rename: a term created as `beverages` and relabelled to `Drinks` is still addressed as `beverages` by every route that takes a `:term`, including DELETE. The slug is the term''s identity — listings reference it and consumer sites build URLs from it — so a rename would silently break inbound links with no alias to catch them (`ListingSlugAlias` covers listings, not terms). To change the addressable name, create the new term, move the listings, delete the old one. **`metadata` is full-replace.** Sending `{ metadata: { synonyms: [...] } }` wipes seoKeywords and every other key. Read-modify-write when partially updating: GET the term, spread its metadata, add your fields, send the merged object back. **Reparent with `parentSlug`**, which is the only parent reference obtainable from this surface — no public route returns a term id. `parentSlug: null` unparents; an absent field leaves the parent alone. `parentTermId` still works, and sending both is a 400.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: taxonomy required: true in: path schema: type: string - name: term required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PublicUpdateTermDto' responses: '200': description: Term updated '400': description: Parent not found in this taxonomy, both `parentSlug` and `parentTermId` sent, a parent set on a flat taxonomy, or a term made its own parent '401': description: Missing or invalid X-API-Key '403': description: API key missing required scope "write" '404': description: Directory, taxonomy, or term not found tags: - Public security: - apiKey: [] delete: operationId: PublicApiController_deleteTerm summary: Delete a taxonomy term description: 'Requires `write`. **Completes the other half of the `resolve-batch` loop** — creating a term from an `unmapped` value became possible with a public key, but undoing a bad mapping still needed a human in the admin UI. Refuses with 409 when anything still depends on the term: - **child terms** — delete or reparent them first; - **assigned listings** — the body carries `listingCount`. Reassign them with `PUT /directories/:directory/listings/:listingType/:listingSlug/terms`, then retry. There is no `force` flag. `ListingTerm` cascades in Postgres, so a permissive delete would silently strip every assignment the term holds and the caller would discover it at the next build. The count is the warning.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: taxonomy required: true in: path schema: type: string - name: term required: true in: path schema: type: string responses: '204': description: Term deleted '401': description: Missing or invalid X-API-Key '403': description: API key missing required scope "write" '404': description: Directory, taxonomy, or term not found '409': description: Term has child terms, or listings are still assigned to it (see `listingCount`) tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/taxonomies/{taxonomy}/resolve: get: operationId: PublicApiController_resolveTerm summary: Resolve a single raw value to a taxonomy term description: Tries term.slug, then term.label, then any string in metadata.synonyms[]. Returns matched=false (no error) when nothing fits — caller decides whether to add a new term or expand synonyms. parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: taxonomy required: true in: path schema: type: string - name: value required: true in: query description: Raw user/source string to resolve schema: type: string responses: '200': description: Resolution result (matched or not) '400': description: Missing ?value '401': description: Missing or invalid X-API-Key '404': description: Directory or taxonomy not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/taxonomies/{taxonomy}/resolve-batch: post: operationId: PublicApiController_resolveTermsBatch summary: Resolve a batch of raw values; report which mapped and which did not description: 'Body: { values: ["AI", "Machine Learning", "DevOps"] }. Response groups results into matched + unmapped — agents typically loop the unmapped list to either create new terms or extend synonyms on existing ones.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: taxonomy required: true in: path schema: type: string responses: '200': description: Resolution report '400': description: Missing or empty values array '401': description: Missing or invalid X-API-Key '404': description: Directory or taxonomy not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/taxonomies/{taxonomy}: get: operationId: PublicApiController_getTaxonomy summary: Get one taxonomy description: No scope required, for the reason the list route gives. Addressed by slug, like everything else on this surface. parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: taxonomy required: true in: path schema: type: string responses: '200': description: The taxonomy, with its term count '404': description: No such taxonomy in this directory tags: - Public security: - apiKey: [] patch: operationId: PublicApiController_updateTaxonomy summary: Rename a taxonomy description: 'Requires `write`. `name` only. `slug` is not settable: it is the address every route here reaches the taxonomy by, and the name `PUT .../terms` uses in a write body, so renaming it through the key that is mid-import strands the importer. Same rule as the directory slug. `structure` is not settable either, and for a sharper reason than caution. Going hierarchical → flat does **not** clear `parentTermId`, so the terms keep their parents while the taxonomy claims to have none — `POST .../terms` starts refusing a parent that existing rows already have, and a consumer building a tree from `parentTermId` renders a hierarchy the API says cannot exist. Doing it safely needs a migration that flattens the terms, which is a feature rather than a field. An empty body is a no-op and fires nothing.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: taxonomy required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UpdateTaxonomyDto' responses: '200': description: The renamed taxonomy '403': description: Key lacks the `write` scope '404': description: No such taxonomy in this directory tags: - Public security: - apiKey: [] delete: operationId: PublicApiController_deleteTaxonomy summary: Delete a taxonomy description: 'Requires `write`. **Two cascades deep:** terms cascade on `taxonomyId` and listing assignments cascade on `termId`, so this can remove thousands of categorisations that took an import to produce. `/export` is the only copy and it is yours. An **empty** taxonomy — the one created by typo, which is why this route exists — deletes with no flag. A taxonomy that still holds terms is refused with **409 carrying `termCount` and `assignmentCount`**; re-send with `?force=true` to confirm those numbers were expected. **Why this takes `force` when `DELETE .../terms/:term` refuses outright.** The term refusal is a detour: reassign the listings with `PUT .../terms` and retry. The same rule here would be a wall — emptying a taxonomy means deleting every term, each of which hits that same hard refusal, so a mistyped taxonomy could never be removed through the API and the answer would again be "ask someone to open the admin UI".' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: taxonomy required: true in: path schema: type: string - name: force required: false in: query description: Set `true` to delete a taxonomy that still holds terms, and its terms and assignments with it. schema: type: string responses: '200': description: Deleted; body reports what went with it '403': description: Key lacks the `write` scope '404': description: No such taxonomy in this directory '409': description: Taxonomy still holds terms and `force` was not set tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listings/batch: post: operationId: PublicApiController_batchListings summary: Batch FETCH listings by (listingType, slug) pairs (read-only) description: 'Body: { items: [{ listingType, slug }] }. Returns matching listings in request order; missing entries are listed in `missing[]` so callers can detect 404s without diffing. Saves N round-trips on subset hydration (e.g. a "compare these three" page). **This endpoint READS. It does not write.** If you are looking for bulk creation, that is `POST .../listings/bulk` — the similar name here has cost integrators time.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: fields required: false in: query description: Comma-separated dotted paths to project on each item. schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BatchListingsDto' responses: '200': description: Batch result with items + missing list '400': description: Empty or oversized items array '401': description: Missing or invalid X-API-Key '404': description: Directory not found or out of API key scope tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listing-aliases: get: operationId: PublicApiController_listSlugAliases summary: The directory's redirect map — every slug a listing has moved off description: 'One call, the whole map. A static build assembling `_redirects` needs (old → new) as a set; `/export` cannot supply it, because an export says where listings live NOW, which is exactly what a 301 does not need. Each row is `{ listingType, slug, currentSlug, currentStatus, listingId, createdAt }` — `slug` is the retired address, `currentSlug` is where it should point. **Check `currentStatus`**: redirecting to a listing that is not published is a 301 into a 404, so skip those rather than emit them. Reads need no particular scope, like the other reads here.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: false in: query description: Filter to one listing type. schema: type: string - name: limit required: false in: query description: Page size, capped at 1000, default 500. schema: type: number - name: offset required: false in: query schema: type: number responses: '200': description: Aliases, newest first, with pagination '400': description: Unknown query parameter '404': description: Directory not found or out of API key scope tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listing-aliases/{listingType}/{listingSlug}: delete: operationId: PublicApiController_deleteSlugAlias summary: Release a retired slug so it can be used again description: 'Requires `write`. The escape hatch for the 409 that a create returns when a slug is reserved as some listing''s former address — without it, every slug a directory has ever renamed away from would be permanently unusable. **This breaks the redirect.** Requests for that address 404 afterwards rather than resolving to the listing that used to answer them. Fires `listing.updated` for the affected listing so consumers rebuild their redirect map.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string responses: '200': description: Alias released '403': description: Key lacks the `write` scope '404': description: No such alias in this directory tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listings/{listingType}/{listingSlug}/attributes: put: operationId: PublicApiController_setAttributes summary: Replace all attribute values on a listing description: Keyed by attribute template `key` (not UUID). Unknown keys return 400 with the list of valid keys for this listing type. parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SetPublicAttributesDto' responses: '200': description: Attributes replaced '400': description: Unknown attribute keys '404': description: Listing not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listings/{listingType}/{listingSlug}/terms: put: operationId: PublicApiController_setTerms summary: Replace all taxonomy term assignments on a listing description: 'Each entry is { taxonomy: , term: }. Sending an empty array clears all terms.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SetPublicTermsDto' responses: '200': description: Term assignments replaced '400': description: Unknown taxonomy/term pairs '404': description: Listing not found tags: - Public security: - apiKey: [] patch: operationId: PublicApiController_patchTerms summary: Add and remove a listing's taxonomy terms without sending the whole set description: 'Requires `write`. Body is `{ "add": [{ taxonomy, term }], "remove": [...] }`; either key may be omitted, but not both. **Why this exists alongside `PUT`.** `PUT` is replace-all, so the documented way to add one tag is to read a listing''s terms and send them back with the new one appended. That is not safe under concurrency: two overlapping read-modify-write cycles each send a complete set, and the later write discards the other''s addition. Nothing errors, nothing is logged, and the listing quietly stops appearing under a category. Replace cannot express "add this one tag", so no amount of caller care fixes it. `PUT` is still the right call for an authoritative full sync, e.g. an importer that owns the whole set. **Idempotent.** Adding a pair the listing already has, or removing one it does not, is a no-op rather than a conflict — so a retried request is safe. A slug that does not exist in this directory is still a 400, including in `remove`: a typo''d removal that silently removed nothing would report success for work it did not do. The same pair in both `add` and `remove` is a 400. Either order is defensible in isolation, so picking one would silently resolve a bug in the caller''s diff. The response carries the listing''s **full resulting term set**, read back after the write — not an echo of the request — so no follow-up GET is needed and a delta that collided with a concurrent write cannot report a set it did not produce.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PatchPublicTermsDto' responses: '200': description: Terms added and/or removed; full resulting set returned '400': description: Neither add nor remove sent, a pair in both, or an unknown taxonomy/term pair (all of them are listed, not just the first) '403': description: Key lacks the `write` scope '404': description: Listing not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listings/{listingType}/{listingSlug}/snapshots: get: operationId: PublicApiController_getSnapshots summary: Read a listing's source snapshots description: 'Requires `read:drafts`. Newest first. Snapshots were previously writable and unreadable — POST existed, there was no public GET, and the operator route rejects an API key. An evidence locker you can write and never open is not an evidence locker. **Why `read:drafts` and not "no scope like other reads":** a snapshot is the raw upstream payload, which is neither published nor a listing. The safest key — `scopes: []`, documented as "read-only, published listings only" — must not reach it, exactly as it must not reach webhook secrets. If you can write snapshots but not read them, ask for `read:drafts`; the scope that lets you write is not the scope that lets you read internal material. Filter with `?source=` to separate your own imports from `google_places` payloads written by enrichment. `payloadHash` is the sha256 of the exact bytes recorded, so two snapshots sharing a hash are the same upstream content — that is how you tell a source genuinely changed from one that was merely re-read.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string - name: source required: false in: query description: Filter to one source, e.g. `google_places`. schema: type: string - name: limit required: false in: query description: Page size, capped at 100. `pagination.limit` reports the limit applied. schema: type: number - name: offset required: false in: query schema: type: number responses: '200': description: Snapshots, newest first, with pagination '400': description: Unknown query parameter '403': description: Key lacks the `read:drafts` scope '404': description: Listing not found or out of API key scope tags: - Public security: - apiKey: [] post: operationId: PublicApiController_createSnapshot summary: Write a raw research/source payload to the listing's evidence locker description: Hermes (and other research agents) post their full upstream payload here for audit and re-derivation. Never exposed via read endpoints. parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreatePublicSnapshotDto' responses: '201': description: Snapshot recorded '404': description: Listing not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listings/{listingType}/{listingSlug}/snapshots/latest/{source}: get: operationId: PublicApiController_getLatestSnapshot summary: Read the newest snapshot from one source description: 'Requires `read:drafts`, like the collection read above and for the same reason: a snapshot is raw upstream payload, and the safest key (`scopes: []`) must not reach it. This is the question the collection read was being used to answer, badly. "What did we last see from `google_places`?" meant pulling a page of snapshots — each carrying its full `payloadJson` — and throwing all but one away. On a listing with 40 snapshots of a 60 kB Places payload that is a 2.4 MB response to a one-row question. 404 if this listing has no snapshot from that source; the body points at the collection read, which lists the sources it does have.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string - name: source required: true in: path schema: type: string responses: '200': description: The newest snapshot from that source '403': description: Key lacks the `read:drafts` scope '404': description: Listing not found, or no snapshot from that source tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listings/{listingType}/{listingSlug}/snapshots/{snapshotId}: get: operationId: PublicApiController_getSnapshot summary: Read one snapshot description: Requires `read:drafts`, as the two reads above do. Ids come from the collection read. parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string - name: snapshotId required: true in: path schema: type: string responses: '200': description: The snapshot '403': description: Key lacks the `read:drafts` scope '404': description: No such snapshot on this listing tags: - Public security: - apiKey: [] delete: operationId: PublicApiController_deleteSnapshot summary: Delete one snapshot description: 'Requires **both** `write` and `read:drafts`. Not belt-and-braces: `write` is the scope that permits a mutation, and `read:drafts` is the scope that admits you to this material at all. A key that may write listings but was never trusted with raw upstream payloads has no business deleting them unseen. **This is the evidence locker.** A snapshot is the exact payload a source returned at a moment in time, and `payloadHash` is what makes "the source changed" a question with an answer — delete it and the next fetch has nothing to diff against. No `force` flag, because the cascade here is benign by design: `DescriptionClaim.snapshotId` is ON DELETE **SET NULL**, not cascade, so a generated description that cited this snapshot keeps its text and stops verifying. A human may have approved that text, and losing it because its evidence was re-fetched would be wrong. The response reports `claimsDetached`, because that is the consequence you will not have anticipated and nothing else would tell you. Contrast `DELETE .../listings/:listingType/:listingSlug`, which is a soft archive and refuses to hard-delete precisely because it WOULD cascade into this table. Deleting one snapshot on purpose is a different act from deleting a listing and taking its evidence as collateral.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string - name: snapshotId required: true in: path schema: type: string responses: '200': description: Deleted; body reports how many claims were detached '403': description: Key lacks `write` or `read:drafts` '404': description: No such snapshot on this listing tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listings/{listingType}/{listingSlug}/enrich: post: operationId: PublicApiController_enrichListing summary: Enrich one listing from Google (geocode or Places) description: 'Runs synchronously and reports exactly what changed. **Only ever fills blanks** — an existing city, phone or website is never overwritten, so this is safe to re-run. `outcome` is `enriched`, `skipped` or `failed`, and a non-enriched result always carries a `reason`. Costs a Google API call per invocation, so it is not called implicitly by any other endpoint. **Hold a Google place id? Send it as `externalId`** with `type: "google_places"` and we fetch that exact place via Place Details instead of guessing. The confidence gate below does not apply — you asserted which place it is — but if the returned name disagrees with the listing, the response carries `nameMismatch` so a stale id is visible rather than silent. **`google_places` matches are confidence-gated.** Places always returns its best guess with no score, so a title that is not a real place (an event name, say) can resolve to an unrelated nearby business. Matches are rejected on region/country disagreement or on low name overlap, and the rejected payload is still stored as a `SourceSnapshot` so you can see what was refused.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/EnrichListingDto' responses: '201': description: Enrichment ran; body reports outcome + fields written '401': description: Missing or invalid X-API-Key '403': description: API key is missing the "write" scope '404': description: Listing or directory not found '503': description: Enrichment is not configured (no GOOGLE_MAPS_API_KEY) or the Google quota is exhausted tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/site-probes/sweep: post: operationId: PublicApiController_sweepProbes summary: Queue site probes for this directory’s listings description: 'Plain HTTP — no browser, no rendering cost. **Two kinds, and neither implies the other.** `kind: "link"` (the default) checks whether each URL resolves, keyed per URL, and is what `dead-links` reports on. `kind: "site"` captures what the page says — title, description, headings, body text, signals — keyed per host and shared across directories. A directory can have every URL confirmed alive and no captured content at all, so `queued` on its own does not tell you which half of the pipeline has run. The response echoes `kind`. **Queued by distinct URL, not per listing.** Forty listings on one domain queue far fewer than forty jobs, and records are shared across directories, so a host another directory already probed costs nothing here. **Asynchronous.** The response reports what was queued; read the results from `GET .../site-probes/dead-links`. Candidates are ordered **oldest-checked first**, so successive runs rotate through a directory bigger than the per-run cap instead of re-checking the same slice forever. Respects `enrichmentPolicy.probe === false`, which short-circuits before anything is queued.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SweepProbesDto' responses: '201': description: Probes queued; body reports the counts '401': description: Missing or invalid X-API-Key '403': description: API key is missing the "write" scope '404': description: Directory not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/site-probes/dead-links: get: operationId: PublicApiController_deadLinks summary: Listings whose site is not answering description: 'Joins this directory’s listings to the shared probe records. **Only `unreachable` counts as dead by default.** A site refusing an automated client — routine behind Cloudflare — is not a broken link, and a report mixing the two teaches an operator to ignore it. 13 of EpicDirectories’ 137 sites answer 403 and are entirely healthy. Pass `includeBlocked=true` to see those as a separate concern. `summary.urlsNeverChecked` is the **gap** in the report, not a pass: those URLs have no evidence either way until a sweep has run, and must not be read as healthy. Likewise `listingsWithoutUrl` can never be checked by anything. **Every item names the `field` its URL came from**, and `source` says whether that was the listing’s own `canonicalFields` or an attribute opted in through `enrichmentPolicy.linkSweepAttributes`. A listing can carry several URLs, so one listing may produce several items — `summary.deadLinks` counts findings and `summary.affectedListings` counts distinct listings. **`liveness: "endpoint"` fields are judged differently.** For a field marked that way in the policy — an API root, typically — any HTTP response of any status proves something is listening, so only an unreachable host or an explicit 410 is dead. A 404 or a 500 at `https://api.example.com/v1` is normal: it is a path prefix, not a resource. `summary.aliveByLivenessRule` counts what that suppressed, so the rule is never invisible. **`enrichmentPolicy.linkDeadOn` widens what counts as dead.** By default only an unreachable host is — a URL whose certificate does not cover its own hostname, or which times out, is recorded as `error`/`blocked` and excluded, so it reads as ALIVE. `{ "tls": true }` counts a certificate fault; `{ "timeoutAfter": 2 }` counts a URL that has timed out that many consecutive checks — not one slow response. `summary.deadOn` echoes both, so the report can be read without the config beside it. **Neither rule is on by default and neither can be global.** Three Convention Grid listings serve 200 at the apex with a `www.` certificate that does not cover the host; that is the owner’s to fix and that report leaves them out on purpose. Enabling `tls` also does not re-admit the six live sites that answer this crawler from a laptop and not from the worker: those are classified `unknown` and stay out. **A row written before failure reasons were recorded is judged the old way**, by outcome alone, and these rules do not fire for it. Guessing a reason from stored error text is the substring match that once filed three live sites as dead domains. The next sweep records it properly. **Three summary fields exist to stop a misconfiguration reading as good news.** `unknownAttributeKeys` lists configured keys with no attribute template here (a typo sweeps nothing and looks healthy); `policyProblems` lists unusable policy entries; `unsweepable` lists values no HTTP request can check at all, such as a `wss://` URL — those are neither checked nor broken. **A URL queued by a sweep still counts as never-checked.** A `LinkCheck` row is written when the probe finishes, not when it is queued, so polling this endpoint straight after a sweep shows no progress for as long as the queue takes to drain. That is a reporting gap, not a stalled sweep. Needs `read:drafts` **or** `write` — a scheduled link check should not require a key that can also delete what it is checking.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: includeBlocked required: false in: query schema: type: boolean responses: '200': description: Affected listings plus a coverage summary '403': description: API key holds neither "read:drafts" nor "write" '404': description: Directory not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/screenshots: post: operationId: PublicApiController_sweepScreenshots summary: Queue homepage screenshots for this directory’s listings description: 'Captures each listing’s own website and attaches the result as a `site_screenshot` media asset, which gets the extra 400x267 `site` variant cropped from the top. Ordinary media from there on — it appears in listing reads and sorts below anything an editor chose. **Asynchronous.** The response reports what was queued, not what was captured; poll `GET .../screenshots` for coverage. Only listings with a resolvable website are eligible — `website`, `url`, `homepage` or `organizerWebsite` in `canonicalFields`, with a `series_of` child inheriting its parent series’ site. `listingsWithoutUrl` in the response is the honest gap: those listings will never get a screenshot from any number of runs. Listings that already have one are skipped unless you pass `force`, so re-running is cheap. Never-captured listings are queued before stale ones, so successive runs rotate through a directory bigger than the per-run cap instead of retrying the same slice forever. Respects `enrichmentPolicy.probe === false`, which short-circuits before anything is queued. **Sizing.** The `site` variant defaults to 400x267 — 400 wide at the capture''s own 3:2. Send `width` alone to change it and the height follows that aspect. To make a size stick across every sweep, set `enrichmentPolicy.screenshot` on the directory instead; `width`/`height` here override it for one run. The resolved geometry comes back as `siteVariant`.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SweepScreenshotsDto' responses: '201': description: Captures queued; body reports the counts '401': description: Missing or invalid X-API-Key '403': description: API key is missing the "write" scope '404': description: Directory not found '503': description: Screenshots are not configured on this deployment tags: - Public security: - apiKey: [] get: operationId: PublicApiController_screenshotCoverage summary: Screenshot coverage for this directory description: 'How many listings have a screenshot, how many are still outstanding, and how many can never have one because they carry no website. `outstanding` is measured against `eligible`, not against the whole directory — a percentage of the total would read as a pipeline still catching up when in fact there is nothing left to do. `nextUp` is a 20-listing sample of what the next sweep would pick up, for spot-checking a run before committing browser time to it. **Requires the `read:drafts` scope**, unlike most reads: `nextUp` and `problems` name listings without filtering by status, so a draft''s slug and URL can appear in either.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string responses: '200': description: Coverage counts plus a sample of what is outstanding '403': description: API key is missing the "read:drafts" scope '404': description: Directory not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/research: post: operationId: PublicApiController_sweepResearch summary: Gather facts from this directory’s listings’ own websites description: 'Reads each listing’s website and writes what it finds into typed attributes prefixed by source — `site_title`, `site_description`, `site_og_image`, `site_headings`, `site_text_excerpt` and the rest. Intended as **seed material for writing your own copy**, not as text to publish: it is what the business says about itself, gathered verbatim. The full raw result is also stored as a `SourceSnapshot` on the listing, so a description written from it can cite the evidence it came from. **Asynchronous.** The response reports what was queued, not what was gathered; poll `GET .../research` for coverage. **Send `declareFields: true` on your first call.** Research only writes into fields the directory has declared a template for. Without them the sweep succeeds and writes nothing, which is the one confusing failure mode this endpoint has. Only listings with a resolvable website are eligible — `website`, `url`, `homepage` or `organizerWebsite` in `canonicalFields`, with a `series_of` child inheriting its parent series’ site. `listingsWithoutUrl` is the honest gap: no number of runs will help those. **Refreshing.** Listings read within `staleAfterDays` (default 90) are skipped, so running this on a schedule re-reads only what has aged. Fields on templates marked `{ derived: true }` are updated in place; anything else is filled only when blank, so a description someone wrote by hand is never overwritten by a sweep. Respects `enrichmentPolicy.probe === false`, which short-circuits before anything is queued.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SweepResearchDto' responses: '201': description: Research queued; body reports the counts per source '401': description: Missing or invalid X-API-Key '403': description: API key is missing the "write" scope '404': description: Directory not found tags: - Public security: - apiKey: [] get: operationId: PublicApiController_researchCoverage summary: Research coverage for this directory description: 'How many listings have been read, how many are outstanding, how many are still fresh, and how many can never be read because they carry no website. `outstanding` is measured against `eligible` rather than the whole directory, so it does not read as a pipeline still catching up when there is nothing left to do. `fields.missing` lists the attribute templates this directory has not declared. Anything named there is a field research will gather and then have nowhere to put — call `POST .../research` with `declareFields: true` to create them. **Requires the `write` scope**, unlike other reads: it counts non-published listings, so it is gated with the sweep it reports on rather than with published reads.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: staleAfterDays required: true in: query schema: type: string responses: '200': description: Coverage counts plus which fields are declared '403': description: API key is missing the "write" scope '404': description: Directory not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/screening: get: operationId: PublicApiController_screeningReport summary: Which listings point at a site that is not really there description: 'Applies this directory’s screening policy to the site-probe evidence already on record. **Fetches nothing.** Run `POST .../site-probes/sweep` with `kind: "site"` first — a link sweep does not capture page content, so screening after one reports every host as unscreened. One verdict per listing, most severe first: - `site-dead` — the host does not resolve - `site-parked` — registrar parking page or for-sale lander - `site-placeholder` — coming-soon page, unedited CMS default, suspended account - `site-thin` — under ~200 characters of text, often a JavaScript-rendered site rather than an empty one **`summary.unscreened` is the gap in the report, not a pass.** Those listings have no evidence either way. `summary.inconclusive` counts hosts that refused us or that we could not reach; neither ever produces a verdict, because a Cloudflare challenge is a fact about our crawler and not about the business.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: term required: true in: query schema: type: string - name: limit required: true in: query schema: type: string responses: '200': description: Flagged listings plus a coverage summary '403': description: API key is missing the "write" scope '404': description: Directory not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/screening/apply: post: operationId: PublicApiController_screeningApply summary: Tag the flagged listings, and hold the confident ones in draft description: 'Writes each verdict as a term in the review taxonomy, creating the taxonomy and only the terms actually earned. **Additive.** Terms are written directly, so a listing’s services, industries and regions cannot be touched by this endpoint — unlike `PUT .../terms`, which is replace-all and requires the caller to send every existing term back or destroy it. **Self-correcting.** A screening term that no longer applies is removed, so a site that comes back stops being tagged as dead. Only the four screening terms are ever removed; your own review tags are never touched. **It never re-publishes.** `site-dead`, `site-parked` and `site-placeholder` pull a published listing back to draft; nothing puts it back, because a listing may have been edited or merged since and silently re-publishing is a change nobody asked for. Recovery is reported and left to a person. Pass `hold: false` for a tag-only pass. **Capped, and it converges.** A run writes at most 2,000 listings and reports the rest as `deferred`; call it again to continue. The cap budgets WORK, not listings — anything already carrying the right term and owing no hold is counted in `settled` and does not consume the budget — so repeated calls finish a directory instead of re-doing the same prefix. `deferred: 0` means done; `settled` is how much was already right. Send `dryRun: true` to see the counts without writing. It previews both halves — `wouldTag`/`wouldHold` for what would be added, `wouldClear` for what would be removed. Watch `wouldClear`: clearing recovered listings is uncapped and is not drawn from the capped selection, so a converged directory can preview zero tags and still have removals pending.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ApplyScreeningDto' responses: '201': description: Counts of what changed '403': description: API key is missing the "write" scope '404': description: Directory not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listings/{listingType}/{listingSlug}/media: get: operationId: PublicApiController_listMedia summary: List media assets for a listing description: 'Returns **every** asset including `pending` and `failed` ones — unlike the read/export endpoints, which only surface `ready` media. This is how you confirm an ingest finished: POST returns `pending`, then poll here until `status` is `ready` (or `failed`, with the reason in `variants.error` when the worker recorded one).' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string - name: status required: false in: query description: 'Filter by status: pending | ready | failed' schema: type: string responses: '200': description: Array of media assets '401': description: Missing or invalid X-API-Key '404': description: Listing or directory not found tags: - Public security: - apiKey: [] post: operationId: PublicApiController_createMedia summary: Attach an image to a listing from a URL description: 'Fetches the URL server-side, stores the original in R2 and queues variant generation, so the asset survives the source URL going away. Returns immediately with `status: "pending"`.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreatePublicMediaDto' responses: '201': description: Media asset created; fetch + variants run async '400': description: Invalid URL — not absolute, not http(s), contains credentials, or points at a private / link-local address '401': description: Missing or invalid X-API-Key '403': description: API key is missing the "write" scope '404': description: Listing or directory not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listings/{listingType}/{listingSlug}/media/upload: post: operationId: PublicApiController_uploadMedia summary: Upload an image file directly (multipart) description: 'Send the bytes. Use this when the image is not already at a public URL — otherwise prefer `POST .../media`, which takes a `url` and is cheaper for both of us. Unlike the URL route this returns `status: "ready"` immediately, because the original is stored before the response; variant generation still runs async. **`Idempotency-Key` is rejected here with 400** — the replay hash cannot see the file bytes, so a reused key could not tell two different images apart. Retry without it and DELETE any duplicate. `role`, `alt`, `caption` and `sortOrder` may be sent as form fields alongside the file. `role` in particular should be set HERE rather than by a follow-up `PATCH`: variant geometry depends on it and variants are enqueued before the response returns, so a role applied afterwards needs an explicit `reprocess` to take effect — two encodes of every byte to reach the state one request could have produced.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string requestBody: required: true content: multipart/form-data: schema: type: object properties: file: type: string format: binary role: type: string enum: - hero - thumbnail - gallery - logo - site_screenshot - site_og description: Asset role. alt: type: string caption: type: string sortOrder: type: integer required: - file responses: '201': description: Media asset created and stored; variants generate async '400': description: Empty file, unsupported MIME type, or an Idempotency-Key was sent '401': description: Missing or invalid X-API-Key '403': description: API key is missing the "write" scope '404': description: Listing or directory not found '413': description: File exceeds 25 MB tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listings/{listingType}/{listingSlug}/media/{assetId}/reprocess: post: operationId: PublicApiController_reprocessMedia summary: Retry processing for a failed media asset description: 'Re-queues variant generation for an asset stuck in `failed` (or `pending`). Use this after a transient upstream failure instead of DELETE + re-upload, which would lose the asset id and any `alt`/`caption` already written against it. Reprocesses from bytes we already hold where possible (`variants.original`, then `cdnUrl`), falling back to the original external URL for a URL-ingest. Returns **409** if the asset has no fetchable source — that happens when the original bytes were never stored and its `sourceRef` is an internal object key rather than a URL.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string - name: assetId required: true in: path schema: type: string responses: '201': description: Re-queued; poll GET .../media until status is ready '401': description: Missing or invalid X-API-Key '403': description: API key is missing the "write" scope '404': description: Media asset, listing or directory not found '409': description: Asset has no fetchable source to reprocess tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/listings/{listingType}/{listingSlug}/media/{assetId}: patch: operationId: PublicApiController_updateMedia summary: Update media metadata (alt, caption, role, sortOrder) description: 'Editorial metadata only. `url`, `variants` and `status` are not writable — they describe bytes we stored, and rewriting them would let a caller point a `ready` asset at content we never fetched. To replace an image, POST a new one and DELETE the old. Unlike listing JSON fields, these are individual columns, so this is a genuine partial update: omitted keys are left alone. Send `""` to clear `alt` or `caption`.' parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string - name: assetId required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UpdatePublicMediaDto' responses: '200': description: Media asset updated '401': description: Missing or invalid X-API-Key '403': description: API key is missing the "write" scope '404': description: Media asset, listing or directory not found tags: - Public security: - apiKey: [] delete: operationId: PublicApiController_deleteMedia summary: Delete a media asset description: Hard delete, unlike listings — a MediaAsset has no children and carries no provenance of its own, so there is nothing to preserve. The stored R2 object is currently left in place. parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: listingType required: true in: path schema: type: string - name: listingSlug required: true in: path schema: type: string - name: assetId required: true in: path schema: type: string responses: '204': description: Media asset deleted '401': description: Missing or invalid X-API-Key '403': description: API key is missing the "write" scope '404': description: Media asset, listing or directory not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/webhooks: get: operationId: PublicApiController_listWebhooks summary: List webhooks for a directory parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string responses: '200': description: Array of webhooks '401': description: Missing or invalid X-API-Key '404': description: Directory not found or out of API key scope tags: - Public security: - apiKey: [] post: operationId: PublicApiController_createWebhook summary: Create a webhook parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateWebhookDto' responses: '201': description: Webhook created '400': description: Validation failed (e.g. invalid URL) '401': description: Missing or invalid X-API-Key '404': description: Directory not found or out of API key scope tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/webhooks/{webhookId}: get: operationId: PublicApiController_getWebhook summary: Get a webhook parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: webhookId required: true in: path schema: type: string responses: '200': description: Webhook details '401': description: Missing or invalid X-API-Key '404': description: Webhook (or directory) not found tags: - Public security: - apiKey: [] patch: operationId: PublicApiController_updateWebhook summary: Update a webhook parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: webhookId required: true in: path schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UpdateWebhookDto' responses: '200': description: Webhook updated '400': description: Validation failed '401': description: Missing or invalid X-API-Key '404': description: Webhook (or directory) not found tags: - Public security: - apiKey: [] delete: operationId: PublicApiController_deleteWebhook summary: Delete a webhook parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: webhookId required: true in: path schema: type: string responses: '204': description: Webhook deleted '401': description: Missing or invalid X-API-Key '404': description: Webhook (or directory) not found tags: - Public security: - apiKey: [] /public/v1/directories/{directory}/webhooks/{webhookId}/test: post: operationId: PublicApiController_testWebhook summary: Fire a test ping at the webhook URL (bypasses debounce) parameters: - name: Idempotency-Key in: header description: Optional. Send a unique key per logical operation to make retries safe. 1-256 characters, and **only** letters, digits, `-` and `_` — colons and other separators are rejected with 400. A retry with the same key replays the original response (status + body) instead of re-executing. Cached for 24h. Reusing a key with a different request body returns 409 — note that a timestamp in the body (e.g. `fetchedAt`) turns an otherwise-stable key into a permanent 409, so either omit it (snapshots default it server-side) or fold a payload digest into the key. Skipped on 5xx responses (transient errors should be retried fresh). required: false schema: type: string - name: x-api-key in: header description: API Key for authentication required: true schema: type: string - name: directory required: true in: path schema: type: string - name: webhookId required: true in: path schema: type: string responses: '201': description: Test ping queued / sent (delivery state on the webhook row) '401': description: Missing or invalid X-API-Key '404': description: Webhook (or directory) not found tags: - Public security: - apiKey: [] components: schemas: SetPublicTermsDto: type: object properties: terms: description: Replace-all set of taxonomy term assignments by slug. Sending an empty array clears all terms. type: array items: $ref: '#/components/schemas/PublicTermAssignmentDto' required: - terms CreatePublicListingDto: type: object properties: listingType: type: string description: Listing type slug (must be defined on the directory) slug: type: string description: URL-friendly slug (unique within directory + listingType) title: type: string description: Display title status: type: string description: Lifecycle status enum: - draft - published - hidden default: draft parentId: type: object description: Parent listing UUID — the id returned on any read. Mutually exclusive with parentSlug; sending both is a 400. Must belong to the same directory. On PATCH, send `null` to detach the listing from its parent. parentSlug: type: string description: Parent listing slug (resolved within the same directory). Requires parentListingType whenever the parent is a different type than this listing — which, for the series/event hierarchy, is always. Prefer parentId if you have it. parentListingType: type: string description: Parent listing type. Defaults to this listing's type when omitted. relationshipType: type: string description: Relationship type to parent (e.g. series_of, brand_of, chapter_of) geo: type: object description: 'Geographic data. **Full-replace on PATCH** — sending `{ geo: { lat: 36.17 } }` wipes every other geo key. To partially update, GET the listing first and merge: `PATCH { geo: { ...existing.geo, lat: 36.17 } }`. **Recognized keys** (free-form JSON, but these are what the admin UI and adapters read): - `lat`, `lng` — numbers - `country` — ISO 3166-1 alpha-2, e.g. `US` - `admin1` — state/region as an ISO 3166-2 fragment, e.g. `OH`. **This is the state field** — `state` and `region` are NOT read, and using them leaves the admin Location section blank. - `city`, `streetAddress`, `postalCode` - `timezone` — IANA, e.g. `America/New_York` Any other key is stored and returned untouched, but nothing renders it.' example: country: US admin1: NV city: Las Vegas lat: 36.17 lng: -115.14 canonicalFields: type: object description: 'Canonical fields (queryable, indexable). Common keys: address, phone, email, website. **Full-replace on PATCH** — sending one key drops every other. Read-modify-write when partially updating.' editorialFields: type: object description: 'Editorial copy. Conventional keys: description, seoTitle, seoDescription, ogImage, noindex, canonicalUrl, synonyms, audiences, pricing. **Full-replace on PATCH** — sending `{ editorialFields: { seoTitle: "X" } }` wipes the description and every other key. Admin UI handles this by GET → spread → PATCH; third-party callers must do the same when partially updating.' publishAt: type: - object - 'null' description: Scheduled publish (ISO 8601). Listings with status:draft + publishAt in the past surface as published on read endpoints. Pass null to clear. taxonomies: type: object description: Taxonomy term assignments. Keys are taxonomy slugs; values are arrays of term slugs. Each taxonomy you list gets its assignments replaced — taxonomies you DON'T list are left alone (per-taxonomy partial-update). All slugs must exist or the whole request returns 400 with the unknown pairs (no partial writes). Use [] to clear a taxonomy. example: industries: - automotive-tradeshows regions: - north-america required: - listingType - slug - title UpdatePublicListingDto: type: object properties: title: type: string description: Display title status: type: string description: Lifecycle status enum: - draft - published - hidden default: draft parentId: type: object description: Parent listing UUID — the id returned on any read. Mutually exclusive with parentSlug; sending both is a 400. Must belong to the same directory. On PATCH, send `null` to detach the listing from its parent. parentSlug: type: string description: Parent listing slug (resolved within the same directory). Requires parentListingType whenever the parent is a different type than this listing — which, for the series/event hierarchy, is always. Prefer parentId if you have it. parentListingType: type: string description: Parent listing type. Defaults to this listing's type when omitted. relationshipType: type: string description: Relationship type to parent (e.g. series_of, brand_of, chapter_of) geo: type: object description: 'Geographic data. **Full-replace on PATCH** — sending `{ geo: { lat: 36.17 } }` wipes every other geo key. To partially update, GET the listing first and merge: `PATCH { geo: { ...existing.geo, lat: 36.17 } }`. **Recognized keys** (free-form JSON, but these are what the admin UI and adapters read): - `lat`, `lng` — numbers - `country` — ISO 3166-1 alpha-2, e.g. `US` - `admin1` — state/region as an ISO 3166-2 fragment, e.g. `OH`. **This is the state field** — `state` and `region` are NOT read, and using them leaves the admin Location section blank. - `city`, `streetAddress`, `postalCode` - `timezone` — IANA, e.g. `America/New_York` Any other key is stored and returned untouched, but nothing renders it.' example: country: US admin1: NV city: Las Vegas lat: 36.17 lng: -115.14 canonicalFields: type: object description: 'Canonical fields (queryable, indexable). Common keys: address, phone, email, website. **Full-replace on PATCH** — sending one key drops every other. Read-modify-write when partially updating.' editorialFields: type: object description: 'Editorial copy. Conventional keys: description, seoTitle, seoDescription, ogImage, noindex, canonicalUrl, synonyms, audiences, pricing. **Full-replace on PATCH** — sending `{ editorialFields: { seoTitle: "X" } }` wipes the description and every other key. Admin UI handles this by GET → spread → PATCH; third-party callers must do the same when partially updating.' publishAt: type: - object - 'null' description: Scheduled publish (ISO 8601). Listings with status:draft + publishAt in the past surface as published on read endpoints. Pass null to clear. taxonomies: type: object description: Taxonomy term assignments. Keys are taxonomy slugs; values are arrays of term slugs. Each taxonomy you list gets its assignments replaced — taxonomies you DON'T list are left alone (per-taxonomy partial-update). All slugs must exist or the whole request returns 400 with the unknown pairs (no partial writes). Use [] to clear a taxonomy. example: industries: - automotive-tradeshows regions: - north-america slug: type: string description: 'New slug. Renaming **preserves the old slug as an alias**: every read that addresses this listing by the old `(listingType, slug)` keeps resolving, and answers with the new slug — so a consumer can compare the two and emit a 301 instead of serving the same listing at two live URLs. `GET .../listing-aliases` returns the whole redirect map for a directory in one call. Renaming back to a slug this listing previously used consumes that alias rather than creating a cycle. A slug held live, or held as an alias by a **different** listing, is a 409.' pattern: ^[a-z0-9-]+$ UpdateRecipeDto: type: object properties: recipeType: type: string description: Recipe type (e.g., collection, landing, feed) slug: type: string description: URL-friendly slug example: top-restaurants-nyc scope: type: object description: Scope definition - filters for which listings to include example: listingType: restaurant taxonomyTerms: - cuisine:italian constraints: type: object description: Constraints for the recipe (e.g., limit, geo bounds) example: limit: 50 radius: 10mi ranking: type: object description: Ranking/sorting configuration example: sortBy: rating order: desc seo: type: object description: SEO metadata for the recipe output example: title: Top Italian Restaurants in NYC metaDescription: '...' SweepResearchDto: type: object properties: limit: type: number description: 'How many listings to queue this run. Capped at 100 regardless of what you send: each job is a request to somebody else''s server, and a directory with ten thousand listings must not be able to point a burst that size at one host. The response reports `deferred` when the cap bit, and the next run picks up where this one stopped.' minimum: 1 maximum: 100 default: 100 force: type: boolean description: Re-read every listing regardless of how recently it was last read. Off by default, so a repeated sweep costs only the listings that have gone stale rather than re-fetching the whole directory. default: false staleAfterDays: type: number description: Treat data older than this many days as stale and re-read it. **This is the knob for periodic refresh** — run the same sweep on a schedule and it re-reads only what has aged past the window. `0` means "everything is stale", which is `force` by another name. minimum: 0 maximum: 3650 default: 90 sources: type: array description: 'Which sources to gather from. Defaults to `site` alone. Each writes into fields prefixed with its own name — `site_title`, `google_rating` — so two sources describing the same thing can never overwrite each other. That convention exists because the unprefixed form already went wrong: a bare `rating` held 2,418 values from a partner import and one from Google, with nothing recording which was which. **`google_places` is billable.** Google charges per request, where `site` costs only a fetch, so it is never included by default — a sweep over thousands of listings has to ask for it. Matches are confidence-gated: Places returns its best guess with no score, and a listing whose title is not really a place would otherwise be attached to an unrelated nearby business.' default: - site items: type: string enum: - site - google_places declareFields: type: boolean description: 'Create the attribute templates these sources write into, if the directory has not declared them already. **Send this once, on your first call.** Research only writes into fields the directory has declared — without a template for `site_title` there is nowhere for the title to go, and the sweep succeeds while writing nothing. This declares all of them in one step instead of eleven separate calls. Templates it creates are marked `{ derived: true, source }`, which is what allows later sweeps to refresh them. **Templates that already exist are left exactly as they are** — if you declared `site_title` yourself and filled it in by hand, this will not quietly convert it into a field a sweep may overwrite.' default: false ingestOg: type: boolean description: 'Fetch each page''s og:image and store it as a `site_og` media asset, rather than keeping only the URL. **On by default.** The URL alone means every image you render is a hotlink to someone else''s CDN. These are marketing sites — they redesign, change host, and rotate cache-busting paths, and when they do your listing shows a broken image with nothing to say it happened. Importing once makes the bytes yours. Costs one fetch and one stored image per listing, so set `false` if you only want the text fields. A listing that already has a `site_og` is left alone — re-running updates the `site_og_image` field but never replaces the stored asset.' default: true listingSlugs: description: 'Research these listings specifically, by slug, instead of letting the sweep choose. A named set is an **instruction**, so the staleness window is bypassed — asking for a listing by name gets it re-read. Unlike a screenshot this destroys nothing: each read is stored as its own snapshot, so the previous evidence survives. Slugs that match nothing come back as `unmatchedSlugs` rather than being silently dropped. Still subject to the per-run cap.' maxItems: 100 example: - acme-digital - bizapedia type: array items: type: string PublicCreateTermDto: type: object properties: slug: type: string description: URL-friendly slug label: type: string description: Display label parentTermId: type: string description: Parent term ID for hierarchical taxonomies metadata: type: object description: 'Free-form metadata: synonyms, seoKeywords, relatedTopics, featured, order, description, etc. Used by the industry-slop normalization workflow.' parentSlug: type: string description: 'Parent term, addressed by slug — the public surface''s identifier. Resolved against this taxonomy only. Mutually exclusive with `parentTermId`: sending both is a 400 rather than a silent precedence rule.' required: - slug - label EnrichListingDto: type: object properties: type: type: string description: 'What to run. `geocode` uses the Google Geocoding API; `google_places` looks the listing up in Places and stores the response as a `SourceSnapshot`. Defaults to `geocode`. `ai_description` is **not** offered: it is withdrawn, not pending.' enum: - geocode - google_places default: geocode direction: type: string description: 'Geocoding direction. - `auto` (default) — **reverse** when the listing already has `lat`/`lng` but is missing `city`, `postalCode`, `admin1` or `country`; **forward** when it has an address but no coordinates; skipped when it has both. - `forward` — address → coordinates. - `reverse` — coordinates → city / admin1 / postalCode / country. This is the one you want if you geocoded client-side and only need the administrative fields filled in.' enum: - auto - forward - reverse default: auto force: type: boolean description: Re-geocode a listing that already has coordinates. Off by default so a re-run is cheap and cannot overwrite hand-corrected coordinates. Even with `force`, existing non-coordinate values are never overwritten — enrichment only ever fills blanks. default: false externalId: type: string description: 'A Google **place id**, for `type: "google_places"` only. When supplied we fetch that exact place via Place Details instead of guessing with a text search. This is the difference between an exact lookup and a fuzzy one. Text search returns its best guess with no confidence score, so the result has to be confidence-gated — and the gate correctly refuses matches like "Business Organization Studio" → "Cozy Home Studios". If you already hold the id, that whole round trip is loss. The gate is skipped for an id lookup, because you asserted which place this is and there is nothing left to guess. If the returned place''s name disagrees with the listing, the response carries `nameMismatch` — the enrichment still applies, but a stale or mistyped id resolves to a real, wrong business and you should see that rather than have it written silently. Both `places/ChIJ…` and a bare `ChIJ…` are accepted.' example: ChIJN1t_tDeuEmsRUsoyG83frY4 ApplyScreeningDto: type: object properties: dryRun: type: boolean description: Report what would change and write nothing. The counts are computed by the same code path that does the writing, so a dry run cannot disagree with the real one. default: false hold: type: boolean description: 'Pull flagged listings back to draft as well as tagging them. On by default: which verdicts unpublish is a property of the policy, not of the caller. Set false for a tag-only pass when you want to read the queue before anything leaves the directory.' default: true limit: type: number description: How many listings to act on, capped at 2000. The response reports `deferred` when the cap bit rather than implying full coverage. minimum: 1 maximum: 2000 default: 2000 PublicSubmissionPolicyDto: type: object properties: suggestions: type: boolean description: Accept anonymous listing suggestions. Each one lands as `pending` for review. feedback: type: boolean description: Accept anonymous feedback on an existing listing. reviews: type: boolean description: Accept anonymous reviews — star-rated, and an approved one moves the listing average. comments: type: boolean description: 'Accept anonymous page comments — unrated, keyed by page path. Independent of `reviews` on purpose: a site can want reader discussion without public star ratings.' SetPublicAttributesDto: type: object properties: attributes: type: object description: Attribute values keyed by attribute template `key` (not UUID). Unknown keys return 400 with the list of valid keys for this listing type. example: start_date: '2026-01-07' venue: Las Vegas Convention Center expected_attendance: 180000 required: - attributes SweepScreenshotsDto: type: object properties: limit: type: number description: How many listings to queue this run. Capped at 50 regardless of what you send — rendering is metered browser time on a shared account, so one directory cannot drain the day for every other consumer. The response reports `deferred` when the cap bit, and the next run picks up where this one stopped. minimum: 1 maximum: 50 default: 50 force: type: boolean description: 'Re-capture listings that already have a screenshot, replacing it. Off by default, so a repeated sweep is cheap and idempotent rather than re-rendering the whole directory. **Use this when the existing capture is the problem** — a challenge page, or a blank that predates the blank-render check. The replacement is stored and live BEFORE the old asset is removed, so a failed re-capture leaves the current screenshot in place rather than clearing it.' default: false width: type: number description: 'Width of the `site` variant for this run, in pixels. **Send this alone and the height follows the capture''s 3:2** — 400 becomes 400x267, 600 becomes 600x400. That is almost always what you want, and computing 266.67 by hand is how a directory ends up with a variant whose aspect quietly crops every screenshot. Capped at 1200, the renderer''s own capture width: anything larger is an upscale of pixels that do not exist, which reads as a rendering bug rather than a choice. Set `enrichmentPolicy.screenshot` on the directory to make a size stick across every sweep; this parameter is for one run.' minimum: 100 maximum: 1200 height: type: number description: Height of the `site` variant, in pixels. Only needed for an aspect other than the capture's 3:2 — and note that a different aspect means cover-fit CROPS, from the top. 400x252 for instance discards about 5.5% of each capture's height. Capped at 800 for the same reason as `width`. minimum: 1 maximum: 800 listingSlugs: description: 'Capture these listings specifically, by slug, instead of letting the sweep choose. A named set is an **instruction**, so the retry window is bypassed — if you can see a listing has no screenshot and ask for it by name, you get an attempt. It does NOT replace an existing screenshot; pass `force` as well for that. Use it to retry a listing whose URL you just fixed, or to re-capture a few at a new size without re-rendering the directory. Slugs that match nothing come back as `unmatchedSlugs` rather than being silently dropped. Still subject to the per-run cap.' maxItems: 50 example: - acme-digital - bizapedia type: array items: type: string CreatePublicMediaDto: type: object properties: url: type: string description: 'Publicly reachable http(s) URL of the image. We fetch it server-side, store the original in R2, and queue variant generation — so the asset survives the source URL going away. The asset is created with `status: "pending"` and flips to `ready` once the original is stored; poll `GET .../media` to watch for it. `data:`, `file:` and private/link-local addresses are rejected with 400.' example: https://cdn.example.com/photos/acme-hall.jpg source: type: string description: Where the image came from, for provenance — e.g. `official_site`, `google_places`, `operator_supplied`. Free-form; defaults to `url`. example: official_site alt: type: string description: Alt text. Set it here — it is the one media field that affects both accessibility and image SEO, and it cannot be inferred from the file. caption: type: string description: Human-readable caption rendered near the image. role: type: string description: Disambiguates what the asset is FOR — consumers pick the page hero by this rather than by position. Closed set; anything else is rejected with 400 rather than silently stored. enum: - hero - thumbnail - gallery - logo - site_screenshot - site_og example: hero sortOrder: type: number description: Display order within the listing, ascending. Ties break on createdAt. minimum: 0 default: 0 required: - url CreatePublicSnapshotDto: type: object properties: source: type: string description: Source identifier — free-form, but use a stable string per provider (e.g. google_places, hermes_research, manual_csv). externalId: type: string description: Upstream record ID for dedup (e.g. Google place_id, scraper URL hash). payload: type: object description: Raw research/source payload to preserve in the evidence locker. fetchedAt: type: string description: When the underlying source data was fetched. Defaults to now if omitted. required: - source - payload BatchListingsDto: type: object properties: items: description: Array of (listingType, slug) pairs to fetch. Maximum 200 per request — split larger sets into multiple calls. Order of returned items mirrors the input order; missing listings are dropped (not returned as nulls) so callers should diff against the request to detect 404s. type: array items: $ref: '#/components/schemas/BatchListingItem' required: - items PatchPublicTermsDto: type: object properties: add: description: Assignments to add, as { taxonomy, term } slug pairs. Adding a pair the listing already has is a no-op, not a conflict — so a retried request is safe. type: array items: $ref: '#/components/schemas/PublicTermAssignmentDto' remove: description: Assignments to remove. Removing a pair the listing does not have is a no-op, for the same reason. type: array items: $ref: '#/components/schemas/PublicTermAssignmentDto' BulkUpsertListingsDto: type: object properties: items: description: Listings to upsert, keyed on (listingType, slug). Max 500. type: array items: $ref: '#/components/schemas/BulkUpsertItemDto' atomic: type: boolean description: 'When true, an item that fails aborts the whole request and nothing is written.\n\nDefault is **false** — per-item results, so one bad row cannot reject 499 good ones. That differs from the rest of this API, where a partial write is never allowed, and the difference is deliberate: at this size, all-or-nothing means a 9,000-listing import fails on row 8,997 and you start again. Set true if you need the stricter contract.' default: false required: - items UpdateWebhookDto: type: object properties: name: type: string url: type: string events: type: array items: type: string enum: - listing.created - listing.updated - listing.deleted - submission.received - review.received - review.approved - review.rejected - comment.received - comment.approved - comment.rejected - comment.updated - listing.boosted - order.received - order.delivered - directory.updated - test.ping isActive: type: boolean secret: type: string debounceMs: type: number minimum: 0 maximum: 600000 PublicTermAssignmentDto: type: object properties: taxonomy: type: string description: Taxonomy slug (e.g. industries) term: type: string description: Term slug within that taxonomy (e.g. technology) required: - taxonomy - term BulkUpsertItemDto: type: object properties: listingType: type: string description: Listing type slug. Must be declared on the directory. slug: type: string description: URL-friendly slug, unique within directory + listingType. title: type: string description: Display title. Required when creating; optional when updating an existing listing, where omitting it leaves the current title alone. status: type: string description: Lifecycle status. Defaults to `draft` on create. geo: type: object description: Structured location. Full-replace, like everywhere else. canonicalFields: type: object description: Free-form canonical fields. Full-replace. editorialFields: type: object description: Free-form editorial fields. Full-replace. taxonomies: type: object description: 'Taxonomy term assignments, keyed by taxonomy slug. Same semantics as on create. An **array** replaces that taxonomy''s assignments; an **object** applies a delta: `{ industries: [''fashion''] }` replaces, `{ industries: { add: [''retail''], remove: [''mining''] } }` adds and removes. Taxonomies not named are untouched either way. The delta form exists because replace cannot express "add one tag", so two concurrent read-modify-write cycles silently lose one of the additions — nothing errors and a listing just stops appearing under a category. Use the array form for an authoritative full sync and the delta form for a retag.' example: industries: add: - fashion remove: - mining attributes: type: object description: 'Attribute values keyed by `AttributeTemplate.key`.\n\nAccepted here but NOT on the single-listing create, deliberately: the single path has `PUT .../attributes` one call away, whereas in bulk that extra call is the difference between 2N and N round trips — which is the entire point of this endpoint.' example: rating: 4.7 parentId: type: string description: Parent listing id, within the same directory. publishAt: type: string description: Scheduled publish time. A past value reads as published. required: - listingType - slug CreateWebhookDto: type: object properties: name: type: string description: Friendly name for the webhook (e.g. "CF Pages deploy hook") url: type: string description: Target URL — POSTed on subscribed events events: type: array description: 'Subscribed event names. Empty array = all events fire this webhook. Known events: `listing.created`, `listing.updated`, `listing.deleted` — fired on writes via either admin or public API; `submission.received` — an anonymous suggestion or feedback item needs review. Fired ONLY for items that reach `pending`; spam (honeypot or failed Turnstile) is stored silently and never notifies, so the channel stays worth reading. Payload: `{ submissionId, kind, listingId }`; `review.received` — a public review is waiting for moderation. Like `submission.received`, fired ONLY for items that reach `pending`. Payload: `{ reviewId, listingId, rating }`; `review.approved` — a review was published and the listing''s rating average moved. Consumers that render ratings from a build-time export need this one to know to rebuild. Payload: `{ reviewId, listingId, rating }`; `review.rejected` — a review that WAS published is no longer public, because it was unpublished, rejected or filed as spam. The mirror of `review.approved`, and the more urgent of the pair: it is what a consumer rebuilds on to take defamatory or abusive text off their site. Fired ONLY on the `approved` -> not-approved transition — rejecting a pending review was never public and fires nothing. Payload: `{ reviewId, listingId, rating }`; `comment.received` — a page comment is waiting for moderation. Fired ONLY for items that reach `pending`. Payload: `{ commentId, path }`; `comment.approved` — a page comment was published. A static site that renders comments rebuilds on this. Payload: `{ commentId, path }`; `comment.rejected` — a page comment that WAS published is no longer public (rejected, unpublished or filed as spam). Fired ONLY on the `approved` -> not-approved transition; subscribe to it if you render comments, since it is what takes one down. Payload: `{ commentId, path }`; `comment.updated` — the editors'' reply on a currently-approved comment was set, changed or cleared. Not fired for replies on unpublished comments. Payload: `{ commentId, path }`; `listing.boosted` — a listing became a premium listing, by purchase or by an operator grant. Payload: `{ listingId, paymentId?, amountCents?, currency? }`. A `listing.updated` is ALWAYS fired alongside it, so a consumer already subscribed to that will rebuild without subscribing to this one; subscribe to this if you want to treat a boost specially. `test.ping` — only fired from the test endpoint, bypasses debounce. Unknown names are REJECTED with 400. They used to be accepted for forward-compat, but a typo then produced a webhook that looked healthy and never fired — subscribe to a new event once it exists.' example: - listing.created - listing.updated items: type: string enum: - listing.created - listing.updated - listing.deleted - submission.received - review.received - review.approved - review.rejected - comment.received - comment.approved - comment.rejected - comment.updated - listing.boosted - order.received - order.delivered - directory.updated - test.ping isActive: type: boolean description: Disable without deleting secret: type: string description: 'Shared secret for HMAC-SHA256 payload signing. When set, every dispatch includes an `X-ED-Signature: sha256=` header — verify on your end by computing HMAC-SHA256 of the raw request body using this secret. Verify each ATTEMPT on its own bytes: a retry re-serialises the body with a fresh `timestamp`, so the signature legitimately differs between attempts of the same delivery. To deduplicate, use `X-ED-Delivery-Id`, which is stable across all attempts — see §13 of the integrator changelog. If unset, no signature header is sent (delivery still works). Write-only: no endpoint ever returns it, not even the create response. Responses carry `hasSecret: true|false` instead. Store your copy at the point you generate it — if you lose it, PATCH a new one rather than expecting to read the old one back.' debounceMs: type: number description: 'Debounce window in milliseconds. Multiple events within the window collapse to a single dispatch — useful for collapsing bulk imports into one rebuild. NULL inherits the global default (30 000ms / 30s). Range: 0 (no debounce) to 600 000 (10 minutes) — the full range is honoured, including values ABOVE the global default, which used to be silently capped. When multiple webhooks for a directory specify different values, the smallest wins for the directory as a whole; webhooks leaving it NULL express no preference and do not drag the others down — v1 limitation.' minimum: 0 maximum: 600000 required: - url UpdateTaxonomyDto: type: object properties: name: type: string description: Display name of the taxonomy CreatePublicJobDto: type: object properties: jobType: type: string description: Type of job. `media_process`, `recipe_build` and `recipe_rebuild_all` exist on the operator API but are refused here — each would be a button that does nothing, and the 400 names the route that actually does the job. enum: - import - enrichment input: type: object description: 'Job input, passed through to the processor. Shape depends on `jobType` — an `import` wants `{ config: { listingType, ... } }`. Validated by the processor rather than here: `inputJson` is stored as sent, so a malformed input surfaces as a job that fails with a reason rather than a request that 400s and leaves no record.' required: - jobType CreateTaxonomyDto: type: object properties: name: type: string description: Display name of the taxonomy slug: type: string description: URL-friendly slug structure: type: string description: Taxonomy structure type enum: - flat - hierarchical default: flat required: - name - slug SweepProbesDto: type: object properties: kind: type: string description: 'What to check. - `link` (default) — **does this URL resolve?** Keyed per URL, because a host-level answer cannot see a dead path on a live domain. This is what `dead-links` reads. - `site` — **what does this page say?** Captures title, description, headings, body text and signals. Keyed per host and shared across directories, so two directories listing the same company pay for one probe. They are not interchangeable and neither implies the other. A directory can have every URL confirmed alive and no captured content at all — which is exactly the state EpicWebDesigners was in when this parameter was added.' enum: - link - site default: link limit: type: number description: 'How many probes to queue this run. Capped at 500 regardless of what you send — probing costs are pooled across every consumer, so one directory cannot drain the budget for the rest. The response reports `deferred` when the cap bit, and successive runs rotate: candidates are ordered oldest-checked first, so a directory larger than the cap is eventually covered rather than stuck on the same slice.' minimum: 1 maximum: 500 default: 500 force: type: boolean description: 'Re-check URLs whose record is still current. Off by default, so a repeated sweep only covers what has gone stale and is cheap to run on a schedule. Use it when the checker itself changed and existing verdicts were computed by the old one.' default: false PublicUpdateTermDto: type: object properties: label: type: string description: Display label. Does NOT change the slug — the term is still addressed by its original slug. parentTermId: type: object description: Parent term ID for hierarchical taxonomies metadata: type: object description: 'Free-form metadata. Conventional keys: synonyms, seoKeywords, relatedTopics, description, seoTitle, seoDescription, featured, order. **Full-replace on PATCH** — sending `{ metadata: { synonyms: [...] } }` wipes seoKeywords and every other key. Read-modify-write when partially updating: GET the term, spread its metadata, add your fields, send the merged object back.' parentSlug: type: - object - 'null' description: 'Parent term, addressed by slug — the public surface''s identifier. Resolved against this taxonomy only. Mutually exclusive with `parentTermId`: sending both is a 400 rather than a silent precedence rule. Send `null` to unparent.' CreateAttributeTemplateDto: type: object properties: listingType: type: string description: Listing type this template applies to (null = all types) key: type: string description: Unique key for the attribute label: type: string description: Human-readable label type: type: string description: Attribute data type enum: - string - text - number - boolean - enum - multi - multi_enum - date - datetime - range - url - email - phone - richtext - json isFilterable: type: boolean description: Whether this attribute can be used in filters default: false isSortable: type: boolean description: Whether this attribute can be used for sorting default: false isSeoRelevant: type: boolean description: Whether this attribute is relevant for SEO default: false enumValues: description: Allowed values for enum/multi_enum types example: - option1 - option2 type: array items: type: string settings: type: object description: Additional settings (validation rules, display hints, etc.) example: min: 0 max: 100 unit: USD required: - key - label - type CreateRecipeDto: type: object properties: recipeType: type: string description: Recipe type (e.g., collection, landing, feed) slug: type: string description: URL-friendly slug example: top-restaurants-nyc scope: type: object description: Scope definition - filters for which listings to include example: listingType: restaurant taxonomyTerms: - cuisine:italian constraints: type: object description: Constraints for the recipe (e.g., limit, geo bounds) example: limit: 50 radius: 10mi ranking: type: object description: Ranking/sorting configuration example: sortBy: rating order: desc seo: type: object description: SEO metadata for the recipe output example: title: Top Italian Restaurants in NYC metaDescription: '...' required: - recipeType - slug - scope UpdatePublicMediaDto: type: object properties: alt: type: string description: Alt text. Send `""` to clear. caption: type: string description: Caption. Send `""` to clear. role: type: string description: Asset role. enum: - hero - thumbnail - gallery - logo - site_screenshot - site_og sortOrder: type: number description: Display order, ascending. minimum: 0 BatchListingItem: type: object properties: listingType: type: string description: Listing type slug example: event slug: type: string description: Listing slug example: aapex-2026 required: - listingType - slug UpdateAttributeTemplateDto: type: object properties: label: type: string description: Human-readable label type: type: string description: Attribute data type enum: - string - text - number - boolean - enum - multi - multi_enum - date - datetime - range - url - email - phone - richtext - json isFilterable: type: boolean description: Whether this attribute can be used in filters default: false isSortable: type: boolean description: Whether this attribute can be used for sorting default: false isSeoRelevant: type: boolean description: Whether this attribute is relevant for SEO default: false enumValues: description: Allowed values for enum/multi_enum types example: - option1 - option2 type: array items: type: string settings: type: object description: Additional settings (validation rules, display hints, etc.) example: min: 0 max: 100 unit: USD UpdatePublicDirectoryDto: type: object properties: name: type: string description: Display name of the directory. example: Ecommerce Web Designers listingTypes: type: object description: Listing types this directory declares, keyed by type slug. Replaces the whole map — send every type you want to keep, not just the new one. Each value needs a non-empty `label`; `description` is optional. Removing a type that still has listings is rejected with 409. example: agency: label: Agency description: An ecommerce design or dev agency submissionPolicy: description: 'What this directory accepts from anonymous strangers. MERGED into the stored policy, not replaced — an absent switch keeps its current value, and keys this DTO does not declare (such as `rejectionReasons`) are preserved. That is the opposite of `listingTypes` above, deliberately: removing a listing type has no other expression, whereas turning one switch off must not silently clear the rest of the object.' allOf: - $ref: '#/components/schemas/PublicSubmissionPolicyDto' securitySchemes: bearer: scheme: bearer bearerFormat: JWT type: http apiKey: type: apiKey in: header name: x-api-key x-provenance: method: harvested publisher: EpicDirectories source: https://api.epicdirectories.com/openapi.json note: split by refine-openapis from the provider's own published document, verified by fetch (openapi/_original/*.fetch.json); stamped by stamp-openapi-provenance.py (roadmap#793)